From 587f2bd7e754ef8f2f7e9cd21eee19a96ca8dc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 18 Jan 2026 15:39:46 +0100 Subject: [PATCH] Initial commit --- .cargo/config.toml | 8 + .github/workflows/deploy.yml | 58 + .gitignore | 30 + CONTRIBUTING.md | 78 + Cargo.lock | 2671 +++++++++++++++++ Cargo.toml | 50 + LICENSE | 662 ++++ README.md | 11 + build-wasm.sh | 13 + doux-sova/Cargo.toml | 14 + doux-sova/README.md | 143 + doux-sova/src/convert.rs | 55 + doux-sova/src/lib.rs | 38 + doux-sova/src/manager.rs | 447 +++ doux-sova/src/receiver.rs | 54 + doux-sova/src/scope.rs | 107 + doux-sova/src/time.rs | 34 + package.json | 10 + src/audio.rs | 141 + src/config.rs | 66 + src/effects/chorus.rs | 134 + src/effects/coarse.rs | 47 + src/effects/comb.rs | 58 + src/effects/crush.rs | 19 + src/effects/distort.rs | 37 + src/effects/flanger.rs | 75 + src/effects/lag.rs | 24 + src/effects/mod.rs | 17 + src/effects/phaser.rs | 50 + src/envelope.rs | 256 ++ src/error.rs | 42 + src/event.rs | 291 ++ src/fastmath.rs | 237 ++ src/filter.rs | 261 ++ src/lib.rs | 676 +++++ src/loader.rs | 266 ++ src/main.rs | 190 ++ src/noise.rs | 87 + src/orbit.rs | 504 ++++ src/osc.rs | 121 + src/oscillator.rs | 421 +++ src/plaits.rs | 212 ++ src/repl.rs | 429 +++ src/sample.rs | 264 ++ src/schedule.rs | 98 + src/telemetry.rs | 112 + src/types.rs | 168 ++ src/voice/mod.rs | 462 +++ src/voice/params.rs | 405 +++ src/voice/source.rs | 213 ++ src/wasm.rs | 385 +++ website/package.json | 24 + website/src/app.css | 298 ++ website/src/app.d.ts | 13 + website/src/app.html | 16 + website/src/content/am.md | 37 + website/src/content/bandpass.md | 69 + website/src/content/basic.md | 103 + website/src/content/chorus.md | 43 + website/src/content/comb.md | 47 + website/src/content/delay.md | 58 + website/src/content/envelope.md | 56 + website/src/content/flanger.md | 43 + website/src/content/frequency-modulation.md | 83 + website/src/content/ftype.md | 21 + website/src/content/gain.md | 45 + website/src/content/highpass.md | 69 + website/src/content/io.md | 25 + website/src/content/lofi.md | 61 + website/src/content/lowpass.md | 69 + website/src/content/oscillator.md | 63 + website/src/content/phaser.md | 53 + website/src/content/pitch-env.md | 49 + website/src/content/pitch.md | 65 + website/src/content/plaits.md | 175 ++ website/src/content/reverb.md | 53 + website/src/content/rm.md | 37 + website/src/content/sample.md | 51 + website/src/content/timing.md | 37 + website/src/content/vibrato.md | 37 + website/src/content/voice.md | 29 + website/src/lib/components/CodeEditor.svelte | 111 + .../src/lib/components/CommandEntry.svelte | 131 + website/src/lib/components/Nav.svelte | 114 + website/src/lib/components/Scope.svelte | 16 + website/src/lib/components/Sidebar.svelte | 122 + website/src/lib/doux.ts | 443 +++ website/src/lib/navigation.json | 132 + website/src/lib/scope.ts | 99 + website/src/lib/types.ts | 37 + website/src/routes/+layout.js | 2 + website/src/routes/+layout.server.ts | 4 + website/src/routes/+layout.svelte | 23 + website/src/routes/+page.svelte | 185 ++ website/src/routes/+page.ts | 3 + website/src/routes/native/+page.svelte | 167 ++ website/src/routes/reference/+page.svelte | 58 + website/src/routes/reference/+page.ts | 41 + website/src/routes/support/+page.svelte | 105 + website/static/.nojekyll | 0 website/static/CNAME | 1 + website/static/coi-serviceworker.min.js | 2 + website/static/doux.wasm | Bin 0 -> 259119 bytes website/svelte.config.js | 22 + website/tsconfig.json | 14 + website/vite.config.ts | 6 + 106 files changed, 14918 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build-wasm.sh create mode 100644 doux-sova/Cargo.toml create mode 100644 doux-sova/README.md create mode 100644 doux-sova/src/convert.rs create mode 100644 doux-sova/src/lib.rs create mode 100644 doux-sova/src/manager.rs create mode 100644 doux-sova/src/receiver.rs create mode 100644 doux-sova/src/scope.rs create mode 100644 doux-sova/src/time.rs create mode 100644 package.json create mode 100644 src/audio.rs create mode 100644 src/config.rs create mode 100644 src/effects/chorus.rs create mode 100644 src/effects/coarse.rs create mode 100644 src/effects/comb.rs create mode 100644 src/effects/crush.rs create mode 100644 src/effects/distort.rs create mode 100644 src/effects/flanger.rs create mode 100644 src/effects/lag.rs create mode 100644 src/effects/mod.rs create mode 100644 src/effects/phaser.rs create mode 100644 src/envelope.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 src/fastmath.rs create mode 100644 src/filter.rs create mode 100644 src/lib.rs create mode 100644 src/loader.rs create mode 100644 src/main.rs create mode 100644 src/noise.rs create mode 100644 src/orbit.rs create mode 100644 src/osc.rs create mode 100644 src/oscillator.rs create mode 100644 src/plaits.rs create mode 100644 src/repl.rs create mode 100644 src/sample.rs create mode 100644 src/schedule.rs create mode 100644 src/telemetry.rs create mode 100644 src/types.rs create mode 100644 src/voice/mod.rs create mode 100644 src/voice/params.rs create mode 100644 src/voice/source.rs create mode 100644 src/wasm.rs create mode 100644 website/package.json create mode 100644 website/src/app.css create mode 100644 website/src/app.d.ts create mode 100644 website/src/app.html create mode 100644 website/src/content/am.md create mode 100644 website/src/content/bandpass.md create mode 100644 website/src/content/basic.md create mode 100644 website/src/content/chorus.md create mode 100644 website/src/content/comb.md create mode 100644 website/src/content/delay.md create mode 100644 website/src/content/envelope.md create mode 100644 website/src/content/flanger.md create mode 100644 website/src/content/frequency-modulation.md create mode 100644 website/src/content/ftype.md create mode 100644 website/src/content/gain.md create mode 100644 website/src/content/highpass.md create mode 100644 website/src/content/io.md create mode 100644 website/src/content/lofi.md create mode 100644 website/src/content/lowpass.md create mode 100644 website/src/content/oscillator.md create mode 100644 website/src/content/phaser.md create mode 100644 website/src/content/pitch-env.md create mode 100644 website/src/content/pitch.md create mode 100644 website/src/content/plaits.md create mode 100644 website/src/content/reverb.md create mode 100644 website/src/content/rm.md create mode 100644 website/src/content/sample.md create mode 100644 website/src/content/timing.md create mode 100644 website/src/content/vibrato.md create mode 100644 website/src/content/voice.md create mode 100644 website/src/lib/components/CodeEditor.svelte create mode 100644 website/src/lib/components/CommandEntry.svelte create mode 100644 website/src/lib/components/Nav.svelte create mode 100644 website/src/lib/components/Scope.svelte create mode 100644 website/src/lib/components/Sidebar.svelte create mode 100644 website/src/lib/doux.ts create mode 100644 website/src/lib/navigation.json create mode 100644 website/src/lib/scope.ts create mode 100644 website/src/lib/types.ts create mode 100644 website/src/routes/+layout.js create mode 100644 website/src/routes/+layout.server.ts create mode 100644 website/src/routes/+layout.svelte create mode 100644 website/src/routes/+page.svelte create mode 100644 website/src/routes/+page.ts create mode 100644 website/src/routes/native/+page.svelte create mode 100644 website/src/routes/reference/+page.svelte create mode 100644 website/src/routes/reference/+page.ts create mode 100644 website/src/routes/support/+page.svelte create mode 100644 website/static/.nojekyll create mode 100644 website/static/CNAME create mode 100644 website/static/coi-serviceworker.min.js create mode 100755 website/static/doux.wasm create mode 100644 website/svelte.config.js create mode 100644 website/tsconfig.json create mode 100644 website/vite.config.ts diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..2f3ad82 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +[target.'cfg(target_arch = "x86_64")'] +rustflags = ["-C", "target-cpu=native"] + +[target.'cfg(target_arch = "aarch64")'] +rustflags = ["-C", "target-cpu=native"] + +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+simd128"] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..eab6d5e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,58 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: website/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install + working-directory: website + + - name: Build + run: pnpm build + working-directory: website + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..268e26d --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Rust +/target +*.rs.bk + +# Node +node_modules/ + +# Lock files (except Cargo.lock for binary reproducibility) +pnpm-lock.yaml + +# SvelteKit +.svelte-kit/ +website/build/ + +# macOS +.DS_Store + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.* +!.env.example + +# Logs +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..810fead --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to Doux + +Contributions are welcome. There are many ways to contribute beyond code: + +- **Bug reports**: Open an issue describing the problem and steps to reproduce. +- **Feature requests**: Suggest new features or improvements. +- **Documentation**: Fix typos, clarify explanations, or add examples. +- **Testing**: Try Doux on different platforms, report issues. +- **Tutorials**: Write guides or share example sessions. +- **Community support**: Help others in issues and discussions. + +## Prerequisites + +Before you start, ensure you have: + +- **Rust** (stable toolchain) - [rustup.rs](https://rustup.rs/) +- **Node.js** (v18+) and **pnpm** - [pnpm.io](https://pnpm.io/) (for website development) + +## Quick start + +```sh +# Build the audio engine +cargo build +cargo clippy + +# Website development +cd website && pnpm install && pnpm dev + +# Build WASM module +./build-wasm.sh +``` + +## Project structure + +- `src/` - Audio engine (Rust) +- `website/` - Documentation and playground (SvelteKit) + +## Code contributions + +1. Fork the repository +2. Create a branch for your changes +3. Make your changes +4. Run `cargo clippy` and fix any warnings +5. Submit a pull request with a clear description of your changes + +Please explain the reasoning behind your changes in the pull request. Document what problem you're solving and how your solution works. This helps reviewers understand your intent and speeds up the review process. + +### Rust + +- Run `cargo clippy` before submitting. +- Avoid cloning to satisfy the borrow checker - find a better solution. + +### TypeScript/Svelte + +- Use pnpm (not npm or yarn). +- Run `pnpm check` for type checking. + +## Code of conduct + +This project follows the [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating, you agree to uphold its standards. We are committed to providing a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, experience level, nationality, appearance, race, religion, or sexual identity. + +**Expected behavior:** +- Demonstrate empathy and kindness +- Respect differing viewpoints and experiences +- Accept constructive feedback gracefully +- Focus on what's best for the community + +**Unacceptable behavior:** +- Harassment, trolling, or personal attacks +- Sexualized language or unwanted advances +- Publishing others' private information +- Any conduct inappropriate in a professional setting + +Report violations to the project maintainers. All complaints will be reviewed promptly and confidentially. + +## License + +By contributing, you agree that your contributions will be licensed under AGPLv3. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..54e45bb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2671 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "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 = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + +[[package]] +name = "audio_thread_priority" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3632611da7e79f8fc8fd75840f1ccfa7792dbf1e25d00791344a4450dd8834f" +dependencies = [ + "cfg-if", + "dbus", + "libc", + "log", + "mach", + "windows-sys 0.52.0", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core" +version = "0.1.0" +source = "git+https://github.com/sova-org/Sova#b82828e9d46c1abe0a40bcc8fb9db1f3d0cfa8f8" +dependencies = [ + "audio_thread_priority", + "clap", + "crossbeam-channel", + "dirs", + "lalrpop", + "lalrpop-util 0.21.0", + "lazy_static", + "midir", + "mlua", + "pest", + "pest_derive", + "rand", + "rhai", + "rmp-serde", + "rosc", + "rusty_link", + "serde", + "serde_json", + "simple-easing", + "thread-priority", + "tokio", + "toml", + "uuid", + "zstd", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "coremidi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964eb3e10ea8b0d29c797086aab3ca730f75e06dced0cb980642fd274a5cca30" +dependencies = [ + "block", + "core-foundation", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" +dependencies = [ + "core-foundation-sys", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dbus" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48b5f0f36f1eebe901b0e6bee369a77ed3396334bf3f09abd46454a576f71819" +dependencies = [ + "libc", + "libdbus-sys", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "doux" +version = "0.1.0" +dependencies = [ + "clap", + "cpal", + "mi-plaits-dsp", + "rosc", + "rustyline", + "symphonia", +] + +[[package]] +name = "doux-sova" +version = "0.1.0" +dependencies = [ + "core", + "cpal", + "crossbeam-channel", + "doux", + "serde", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[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", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util 0.22.2", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "108dc8f5dabad92c65a03523055577d847f5dcc00f3e7d3a68bc4d48e01d8fe1" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "luau0-src" +version = "0.17.1+luau702" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e879d82bf7e2e682218f451256f934b7bf8e5703fc2a866eeb4a32e3e79886" +dependencies = [ + "cc", +] + +[[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + +[[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.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mi-plaits-dsp" +version = "0.1.0" +source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4#dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4" +dependencies = [ + "dyn-clone", + "num-traits", + "spin", +] + +[[package]] +name = "midir" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73f8737248ad37b88291a2108d9df5f991dc8555103597d586b5a29d4d703c0" +dependencies = [ + "alsa", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "parking_lot", + "wasm-bindgen", + "web-sys", + "windows 0.56.0", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935ac67539907efcd7198137eb7358e052555f77fe1b2916600a2249351f2b33" +dependencies = [ + "bstr", + "either", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", +] + +[[package]] +name = "mlua-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c968af21bf6b19fc9ca8e7b85ee16f86e4c9e3d0591de101a5608086bda0ad8" +dependencies = [ + "cc", + "cfg-if", + "luau0-src", + "pkg-config", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[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.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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 = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rhai" +version = "1.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "instant", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rosc" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e63d9e6b0d090be1485cf159b1e04c3973d2d3e1614963544ea2ff47a4a981" +dependencies = [ + "byteorder", + "nom", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty_link" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431fec7d301f2c3668244fe013938073ef33eb39615696bfd659111d4b4d1089" +dependencies = [ + "bindgen", + "cmake", +] + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simple-easing" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832ddd7df0d98d6fd93b973c330b7c8e0742d5cb8f1afc7dea89dba4d2531aa1" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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-pcm", + "symphonia-core", + "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-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[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-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.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[package]] +name = "thread-priority" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "libc", + "log", + "rustversion", + "winapi", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "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-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..987a21f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +[workspace] +members = [".", "doux-sova"] + +[package] +name = "doux" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "doux" +path = "src/main.rs" +required-features = ["native"] + +[[bin]] +name = "doux-repl" +path = "src/repl.rs" +required-features = ["native"] + +[features] +default = ["native"] +native = ["dep:clap", "dep:cpal", "dep:rosc", "dep:symphonia", "dep:rustyline"] + +[dependencies] +clap = { version = "4", optional = true, features = ["derive"] } +cpal = { version = "0.15", optional = true } +rosc = { version = "0.10", optional = true } +symphonia = { version = "0.5", optional = true, default-features = false, features = ["wav", "pcm", "mp3", "ogg", "flac", "aac"] } +rustyline = { version = "14", optional = true } +mi-plaits-dsp = { git = "https://github.com/sourcebox/mi-plaits-dsp-rs", rev = "dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4" } + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" + +[profile.release-with-debug] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2beb9e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf8085e --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# doux + +A software synthesizer engine for live coding, written in Rust. Ported from [Dough](https://dough.strudel.cc/) by Felix Roos and al. Documentation and live playground can be found on this page: [doux](https://doux.livecoding.fr). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +This project is licensed under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/build-wasm.sh b/build-wasm.sh new file mode 100755 index 0000000..029276e --- /dev/null +++ b/build-wasm.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Building WASM..." +cargo build --target wasm32-unknown-unknown --release --no-default-features + +echo "Copying to website/static/..." +cp target/wasm32-unknown-unknown/release/doux.wasm website/static/doux.wasm + +echo "Verifying..." +ls -la website/static/doux.wasm +echo "" +echo "Done! WASM updated in website/static/" diff --git a/doux-sova/Cargo.toml b/doux-sova/Cargo.toml new file mode 100644 index 0000000..90d1d74 --- /dev/null +++ b/doux-sova/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "doux-sova" +version = "0.1.0" +edition = "2021" + +[dependencies] +doux = { path = "..", features = ["native"] } +crossbeam-channel = "0.5" +cpal = "0.15" +serde = { version = "1", features = ["derive"] } + +[dependencies.sova_core] +package = "core" +git = "https://github.com/sova-org/Sova" diff --git a/doux-sova/README.md b/doux-sova/README.md new file mode 100644 index 0000000..ec63148 --- /dev/null +++ b/doux-sova/README.md @@ -0,0 +1,143 @@ +# doux-sova + +Integration layer connecting Doux audio engine to Sova's AudioEngineProxy. + +## Quick Start with DouxManager + +```rust +use doux_sova::{DouxManager, DouxConfig, audio}; + +// 1. List available devices (for UI) +let devices = audio::list_output_devices(); +for dev in &devices { + let default_marker = if dev.is_default { " [default]" } else { "" }; + println!("{}: {} (max {} ch){}", dev.index, dev.name, dev.max_channels, default_marker); +} + +// 2. Create configuration +let config = DouxConfig::default() + .with_output_device("Built-in Output") + .with_channels(2) + .with_buffer_size(512) + .with_sample_path("/path/to/samples"); + +// 3. Create and start manager +let mut manager = DouxManager::new(config)?; +let proxy = manager.start(clock.micros())?; + +// 4. Register with Sova +device_map.connect_audio_engine("doux", proxy)?; + +// 5. Control during runtime +manager.hush(); // Release all voices +manager.panic(); // Immediately stop all voices +manager.add_sample_path(path); // Add more samples +manager.rescan_samples(); // Rescan all sample directories +let state = manager.state(); // Get AudioEngineState (voices, cpu, etc) +let scope = manager.scope_capture(); // Get oscilloscope buffer + +// 6. Stop or restart +manager.stop(); // Stop audio streams +let new_config = DouxConfig::default().with_channels(4); +let new_proxy = manager.restart(new_config, clock.micros())?; +``` + +## Low-Level API + +For direct engine access without lifecycle management: + +```rust +use std::sync::{Arc, Mutex}; +use doux::Engine; +use doux_sova::create_integration; + +// 1. Create the doux engine +let engine = Arc::new(Mutex::new(Engine::new(44100.0))); + +// 2. Get initial sync time from Sova's clock +let initial_time = clock.micros(); + +// 3. Create integration - returns thread handle and proxy +let (handle, proxy) = create_integration(engine.clone(), initial_time); + +// 4. Register proxy with Sova's device map +device_map.connect_audio_engine("doux", proxy)?; + +// 5. Start your audio output loop using the engine +// (see doux/src/main.rs for cpal example) +``` + +## Architecture + +``` +Sova Scheduler + │ + ▼ +AudioEngineProxy::send(AudioEnginePayload) + │ + ▼ (crossbeam channel) + │ +SovaReceiver thread + │ converts HashMap → command string + ▼ +Engine::evaluate("/s/sine/freq/440/gain/0.5/...") +``` + +## Time Synchronization + +Pass `clock.micros()` at engine startup. Sova's timetags (microseconds) are converted to engine time (seconds) relative to this initial value. Events with timetags go to Doux's scheduler for sample-accurate playback. + +## DouxConfig Methods + +| Method | Description | +|--------|-------------| +| `with_output_device(name)` | Set output device by name | +| `with_input_device(name)` | Set input device by name | +| `with_channels(n)` | Set number of output channels | +| `with_buffer_size(n)` | Set audio buffer size in frames | +| `with_sample_path(path)` | Add a single sample directory | +| `with_sample_paths(paths)` | Add multiple sample directories | + +## DouxManager Methods + +| Method | Description | +|--------|-------------| +| `start(time)` | Start audio streams, returns proxy | +| `stop()` | Stop audio streams | +| `restart(config, time)` | Restart with new configuration | +| `hush()` | Release all voices gracefully | +| `panic()` | Immediately stop all voices | +| `add_sample_path(path)` | Add sample directory | +| `rescan_samples()` | Rescan all sample directories | +| `clear_samples()` | Clear sample pool | +| `state()` | Returns `AudioEngineState` | +| `is_running()` | Check if streams are active | +| `sample_rate()` | Get current sample rate | +| `channels()` | Get number of channels | +| `config()` | Get current configuration | +| `engine_handle()` | Access engine for telemetry | +| `scope_capture()` | Get oscilloscope buffer | + +## Re-exported Types + +### AudioEngineState + +```rust +pub struct AudioEngineState { + pub active_voices: u32, + pub cpu_percent: f32, + pub output_level: f32, +} +``` + +### DouxError + +Error type for manager operations (device not found, stream errors, etc). + +### ScopeCapture + +Buffer containing oscilloscope data from `scope_capture()`. + +## Parameters + +All Sova Dirt parameters map directly to Doux event fields via `Event::parse()`. No manual mapping required - add new parameters to Doux and they work automatically. diff --git a/doux-sova/src/convert.rs b/doux-sova/src/convert.rs new file mode 100644 index 0000000..51b07c8 --- /dev/null +++ b/doux-sova/src/convert.rs @@ -0,0 +1,55 @@ +//! Payload conversion from Sova to Doux command format. +//! +//! Converts Sova's `AudioEnginePayload` (HashMap of parameters) into +//! Doux's slash-separated command strings (e.g., `/sound/sine/freq/440`). + +use std::collections::HashMap; + +use sova_core::clock::SyncTime; +use sova_core::vm::variable::VariableValue; + +use crate::time::TimeConverter; + +/// Converts a Sova payload to a Doux command string. +/// +/// The resulting string has the format `/key/value/key/value/...`. +/// If a timetag is present, it's converted to engine time and prepended. +pub fn payload_to_command( + args: &HashMap, + timetag: Option, + time_converter: &TimeConverter, +) -> String { + let mut parts = Vec::new(); + + if let Some(tt) = timetag { + let engine_time = time_converter.sync_to_engine_time(tt); + parts.push("time".to_string()); + parts.push(engine_time.to_string()); + } + + for (key, value) in args { + parts.push(key.clone()); + parts.push(value_to_string(value)); + } + + format!("/{}", parts.join("/")) +} + +/// Converts a Sova variable value to a string for Doux. +fn value_to_string(v: &VariableValue) -> String { + match v { + VariableValue::Integer(i) => i.to_string(), + VariableValue::Float(f) => f.to_string(), + VariableValue::Decimal(sign, num, den) => { + let f = (*num as f64) / (*den as f64); + if *sign < 0 { + format!("-{f}") + } else { + f.to_string() + } + } + VariableValue::Str(s) => s.clone(), + VariableValue::Bool(b) => if *b { "1" } else { "0" }.to_string(), + _ => String::new(), + } +} diff --git a/doux-sova/src/lib.rs b/doux-sova/src/lib.rs new file mode 100644 index 0000000..95cdd0e --- /dev/null +++ b/doux-sova/src/lib.rs @@ -0,0 +1,38 @@ +mod convert; +pub mod manager; +mod receiver; +pub mod scope; +mod time; + +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::unbounded; +use doux::Engine; +use sova_core::clock::SyncTime; +use sova_core::protocol::audio_engine_proxy::AudioEngineProxy; + +use receiver::SovaReceiver; +use time::TimeConverter; + +// Re-exports for convenience +pub use doux::audio; +pub use doux::config::DouxConfig; +pub use doux::error::DouxError; +pub use manager::{AudioEngineState, DouxManager}; +pub use scope::ScopeCapture; + +/// Creates a Sova integration for an existing engine. +/// +/// This is the low-level API. For most use cases, prefer `DouxManager` +/// which handles the full engine lifecycle. +pub fn create_integration( + engine: Arc>, + initial_sync_time: SyncTime, +) -> (JoinHandle<()>, AudioEngineProxy) { + let (tx, rx) = unbounded(); + let time_converter = TimeConverter::new(initial_sync_time); + let receiver = SovaReceiver::new(engine, rx, time_converter); + let handle = thread::spawn(move || receiver.run()); + (handle, AudioEngineProxy::new(tx)) +} diff --git a/doux-sova/src/manager.rs b/doux-sova/src/manager.rs new file mode 100644 index 0000000..8466703 --- /dev/null +++ b/doux-sova/src/manager.rs @@ -0,0 +1,447 @@ +//! DouxManager - Lifecycle management for the Doux audio engine with Sova integration. +//! +//! Provides a high-level API for managing the complete audio engine lifecycle, +//! including device selection, stream creation, and Sova scheduler integration. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use cpal::traits::{DeviceTrait, StreamTrait}; +use cpal::{Device, Stream, SupportedStreamConfig}; +use crossbeam_channel::Sender; +use serde::{Deserialize, Serialize}; + +use doux::audio::{ + default_input_device, default_output_device, find_input_device, find_output_device, + max_output_channels, +}; +use doux::config::DouxConfig; +use doux::error::DouxError; +use doux::Engine; + +use sova_core::clock::SyncTime; +use sova_core::protocol::audio_engine_proxy::{AudioEnginePayload, AudioEngineProxy}; + +use crate::receiver::SovaReceiver; +use crate::scope::ScopeCapture; +use crate::time::TimeConverter; + +/// Snapshot of the audio engine state for external visibility. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioEngineState { + /// Whether audio streams are currently running. + pub running: bool, + /// Name of the output device, or None if using system default. + pub device: Option, + /// Sample rate in Hz. + pub sample_rate: f32, + /// Number of output channels. + pub channels: usize, + /// Requested buffer size in samples, if explicitly set. + pub buffer_size: Option, + /// Number of currently playing voices. + pub active_voices: usize, + /// Configured sample directory paths. + pub sample_paths: Vec, + /// Last error message, if any. + pub error: Option, + /// CPU load as a fraction (0.0 to 1.0+). + pub cpu_load: f32, + /// Peak number of voices seen since reset. + pub peak_voices: usize, + /// Maximum allowed voices. + pub max_voices: usize, + /// Number of events in the schedule queue. + pub schedule_depth: usize, + /// Total memory used by sample pool in megabytes. + pub sample_pool_mb: f32, +} + +impl Default for AudioEngineState { + fn default() -> Self { + Self { + running: false, + device: None, + sample_rate: 0.0, + channels: 0, + buffer_size: None, + active_voices: 0, + sample_paths: Vec::new(), + error: None, + cpu_load: 0.0, + peak_voices: 0, + max_voices: doux::types::MAX_VOICES, + schedule_depth: 0, + sample_pool_mb: 0.0, + } + } +} + +/// Manages the Doux audio engine lifecycle with Sova integration. +/// +/// Handles creating, starting, stopping, and restarting the audio engine +/// with different configurations. +pub struct DouxManager { + /// The audio synthesis engine, shared with the audio callback thread. + engine: Arc>, + /// Current configuration (device, channels, sample paths). + config: DouxConfig, + /// Actual sample rate from the audio device. + sample_rate: f32, + /// Actual channel count (may be clamped to device maximum). + actual_channels: usize, + /// Handle to the CPAL output stream, None when stopped. + output_stream: Option, + /// Handle to the CPAL input stream, None when stopped or no input. + input_stream: Option, + /// Handle to the Sova receiver thread. + receiver_handle: Option>, + /// Sender end of the channel to the receiver, dropped to signal shutdown. + proxy_sender: Option>, + /// Scope capture for oscilloscope display. + scope: Option>, +} + +/// Resolves the output device from config, returning an error if not found. +fn resolve_output_device(config: &DouxConfig) -> Result { + match &config.output_device { + Some(spec) => { + find_output_device(spec).ok_or_else(|| DouxError::DeviceNotFound(spec.clone())) + } + None => default_output_device().ok_or(DouxError::NoDefaultDevice), + } +} + +/// Gets the device configuration and extracts the sample rate. +fn get_device_config(device: &Device) -> Result<(SupportedStreamConfig, f32), DouxError> { + let config = device + .default_output_config() + .map_err(|e| DouxError::DeviceConfigError(e.to_string()))?; + let sample_rate = config.sample_rate().0 as f32; + Ok((config, sample_rate)) +} + +/// Computes the actual channel count, clamped to the device maximum. +fn compute_channels(device: &Device, requested: u16) -> usize { + let max_ch = max_output_channels(device); + (requested as usize).min(max_ch as usize) +} + +impl DouxManager { + /// Creates a new DouxManager with the given configuration. + /// + /// This resolves the audio device and creates the engine, but does not + /// start the audio streams. Call `start()` to begin audio processing. + pub fn new(config: DouxConfig) -> Result { + let output_device = resolve_output_device(&config)?; + let (_, sample_rate) = get_device_config(&output_device)?; + let actual_channels = compute_channels(&output_device, config.channels); + + // Create engine + let mut engine = Engine::new_with_channels(sample_rate, actual_channels); + + // Load sample directories + for path in &config.sample_paths { + let index = doux::loader::scan_samples_dir(path); + engine.sample_index.extend(index); + } + + Ok(Self { + engine: Arc::new(Mutex::new(engine)), + config, + sample_rate, + actual_channels, + output_stream: None, + input_stream: None, + receiver_handle: None, + proxy_sender: None, + scope: None, + }) + } + + /// Starts the audio streams and returns an AudioEngineProxy for Sova. + /// + /// The proxy can be registered with Sova's device map to receive events. + pub fn start(&mut self, initial_sync_time: SyncTime) -> Result { + let output_device = resolve_output_device(&self.config)?; + let (device_config, _) = get_device_config(&output_device)?; + + let stream_config = cpal::StreamConfig { + channels: self.actual_channels as u16, + sample_rate: device_config.sample_rate(), + buffer_size: self + .config + .buffer_size + .map(cpal::BufferSize::Fixed) + .unwrap_or(cpal::BufferSize::Default), + }; + + // Ring buffer for live audio input + let input_buffer: Arc>> = + Arc::new(Mutex::new(VecDeque::with_capacity(8192))); + + // Set up input stream if configured + let input_device = match &self.config.input_device { + Some(spec) => find_input_device(spec), + None => default_input_device(), + }; + + self.input_stream = input_device.and_then(|input_dev| { + let input_config = input_dev.default_input_config().ok()?; + let buf = Arc::clone(&input_buffer); + let stream = input_dev + .build_input_stream( + &input_config.into(), + move |data: &[f32], _| { + let mut b = buf.lock().unwrap(); + for &sample in data { + b.push_back(sample); + if b.len() > 8192 { + b.pop_front(); + } + } + }, + |err| eprintln!("input stream error: {err}"), + None, + ) + .ok()?; + stream.play().ok()?; + Some(stream) + }); + + // Create scope capture for oscilloscope + let scope = Arc::new(ScopeCapture::new()); + let scope_clone = Arc::clone(&scope); + + // Build output stream + let engine_clone = Arc::clone(&self.engine); + let input_buf_clone = Arc::clone(&input_buffer); + let live_scratch: Arc>> = Arc::new(Mutex::new(vec![0.0; 1024])); + let live_scratch_clone = Arc::clone(&live_scratch); + let sample_rate = self.sample_rate; + let output_channels = self.actual_channels; + + let output_stream = output_device + .build_output_stream( + &stream_config, + move |data: &mut [f32], _| { + let mut buf = input_buf_clone.lock().unwrap(); + let mut scratch = live_scratch_clone.lock().unwrap(); + if scratch.len() < data.len() { + scratch.resize(data.len(), 0.0); + } + for sample in scratch[..data.len()].iter_mut() { + *sample = buf.pop_front().unwrap_or(0.0); + } + drop(buf); + let mut engine = engine_clone.lock().unwrap(); + // Set buffer time budget for CPU load measurement + let buffer_samples = data.len() / output_channels; + let buffer_time_ns = (buffer_samples as f64 / sample_rate as f64 * 1e9) as u64; + engine.metrics.load.set_buffer_time(buffer_time_ns); + engine.process_block(data, &[], &scratch[..data.len()]); + // Capture samples for oscilloscope (zero-allocation path) + for chunk in data.chunks(output_channels) { + if output_channels >= 2 { + scope_clone.push_stereo(chunk[0], chunk[1]); + } else { + scope_clone.push_mono(chunk[0]); + } + } + }, + |err| eprintln!("output stream error: {err}"), + None, + ) + .map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?; + + output_stream + .play() + .map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?; + + self.output_stream = Some(output_stream); + self.scope = Some(scope); + + // Create Sova integration + let (tx, rx) = crossbeam_channel::unbounded(); + let time_converter = TimeConverter::new(initial_sync_time); + let receiver = SovaReceiver::new(Arc::clone(&self.engine), rx, time_converter); + let handle = std::thread::spawn(move || receiver.run()); + + self.receiver_handle = Some(handle); + self.proxy_sender = Some(tx.clone()); + + Ok(AudioEngineProxy::new(tx)) + } + + /// Stops all audio streams and the Sova receiver. + pub fn stop(&mut self) { + // Drop streams to stop audio + self.output_stream = None; + self.input_stream = None; + self.scope = None; + + // Drop sender to signal receiver to stop + self.proxy_sender = None; + + // Wait for receiver thread to finish (it will exit when channel closes) + if let Some(handle) = self.receiver_handle.take() { + let _ = handle.join(); + } + } + + /// Restarts the engine with a new configuration. + /// + /// Stops the current engine, creates a new one with the new config, + /// and returns a new AudioEngineProxy. + pub fn restart( + &mut self, + config: DouxConfig, + initial_sync_time: SyncTime, + ) -> Result { + self.stop(); + + let output_device = resolve_output_device(&config)?; + let (_, sample_rate) = get_device_config(&output_device)?; + let actual_channels = compute_channels(&output_device, config.channels); + + // Create new engine + let mut engine = Engine::new_with_channels(sample_rate, actual_channels); + + for path in &config.sample_paths { + let index = doux::loader::scan_samples_dir(path); + engine.sample_index.extend(index); + } + + self.engine = Arc::new(Mutex::new(engine)); + self.config = config; + self.sample_rate = sample_rate; + self.actual_channels = actual_channels; + + self.start(initial_sync_time) + } + + /// Returns the actual sample rate being used. + pub fn sample_rate(&self) -> f32 { + self.sample_rate + } + + /// Returns the actual number of output channels. + pub fn channels(&self) -> usize { + self.actual_channels + } + + /// Returns the current configuration. + pub fn config(&self) -> &DouxConfig { + &self.config + } + + /// Returns whether audio streams are running. + pub fn is_running(&self) -> bool { + self.output_stream.is_some() + } + + /// Returns a snapshot of the current audio engine state. + pub fn state(&self) -> AudioEngineState { + use std::sync::atomic::Ordering; + + let (active_voices, cpu_load, peak_voices, schedule_depth, sample_pool_mb) = self + .engine + .lock() + .map(|e| { + ( + e.active_voices, + e.metrics.load.get_load(), + e.metrics.peak_voices.load(Ordering::Relaxed) as usize, + e.metrics.schedule_depth.load(Ordering::Relaxed) as usize, + e.metrics.sample_pool_mb(), + ) + }) + .unwrap_or((0, 0.0, 0, 0, 0.0)); + + AudioEngineState { + running: self.is_running(), + device: self + .config + .output_device + .clone() + .or_else(|| Some("System Default".to_string())), + sample_rate: self.sample_rate, + channels: self.actual_channels, + buffer_size: self.config.buffer_size, + active_voices, + sample_paths: self.config.sample_paths.clone(), + error: None, + cpu_load, + peak_voices, + max_voices: doux::types::MAX_VOICES, + schedule_depth, + sample_pool_mb, + } + } + + /// Adds a sample directory and scans it. + pub fn add_sample_path(&mut self, path: std::path::PathBuf) { + let index = doux::loader::scan_samples_dir(&path); + if let Ok(mut engine) = self.engine.lock() { + engine.sample_index.extend(index); + } + self.config.sample_paths.push(path); + } + + /// Rescans all configured sample directories. + pub fn rescan_samples(&mut self) { + if let Ok(mut engine) = self.engine.lock() { + engine.sample_index.clear(); + for path in &self.config.sample_paths { + let index = doux::loader::scan_samples_dir(path); + engine.sample_index.extend(index); + } + } + } + + /// Clears all loaded samples. + pub fn clear_samples(&mut self) { + if let Ok(mut engine) = self.engine.lock() { + engine.sample_index.clear(); + engine.samples.clear(); + engine.sample_pool = doux::sample::SamplePool::new(); + } + } + + /// Sends a hush command to release all voices. + pub fn hush(&self) { + if let Ok(mut engine) = self.engine.lock() { + engine.hush(); + } + } + + /// Sends a panic command to immediately stop all voices. + pub fn panic(&self) { + if let Ok(mut engine) = self.engine.lock() { + engine.panic(); + } + } + + /// Returns a handle to the engine for telemetry access. + /// + /// This allows external code to read engine metrics without holding + /// the entire DouxManager (which is not Send due to cpal::Stream). + pub fn engine_handle(&self) -> Arc> { + Arc::clone(&self.engine) + } + + /// Returns the scope capture for oscilloscope display. + /// + /// Returns None if the audio engine is not running. + pub fn scope_capture(&self) -> Option> { + self.scope.clone() + } +} + +impl Drop for DouxManager { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/doux-sova/src/receiver.rs b/doux-sova/src/receiver.rs new file mode 100644 index 0000000..6eacfc3 --- /dev/null +++ b/doux-sova/src/receiver.rs @@ -0,0 +1,54 @@ +//! Sova event receiver thread. +//! +//! Listens for events from Sova's scheduler via a crossbeam channel and +//! forwards them to the Doux engine as command strings. + +use std::sync::{Arc, Mutex}; + +use crossbeam_channel::Receiver; +use doux::Engine; +use sova_core::protocol::audio_engine_proxy::AudioEnginePayload; + +use crate::convert::payload_to_command; +use crate::time::TimeConverter; + +/// Receives events from Sova and forwards them to the Doux engine. +/// +/// Runs in a dedicated thread, blocking on channel receive. Exits when +/// the sender is dropped (channel closed). +pub struct SovaReceiver { + /// Shared reference to the audio engine. + engine: Arc>, + /// Channel receiving events from Sova's scheduler. + rx: Receiver, + /// Converts Sova timestamps to engine time. + time_converter: TimeConverter, +} + +impl SovaReceiver { + /// Creates a new receiver with the given engine, channel, and time converter. + pub fn new( + engine: Arc>, + rx: Receiver, + time_converter: TimeConverter, + ) -> Self { + Self { + engine, + rx, + time_converter, + } + } + + /// Runs the receiver loop until the channel is closed. + /// + /// Each received payload is converted to a Doux command string + /// and evaluated by the engine. + pub fn run(self) { + while let Ok(payload) = self.rx.recv() { + let cmd = payload_to_command(&payload.args, payload.timetag, &self.time_converter); + if let Ok(mut engine) = self.engine.lock() { + engine.evaluate(&cmd); + } + } + } +} diff --git a/doux-sova/src/scope.rs b/doux-sova/src/scope.rs new file mode 100644 index 0000000..f74c8d2 --- /dev/null +++ b/doux-sova/src/scope.rs @@ -0,0 +1,107 @@ +//! Lock-free oscilloscope capture for the audio engine. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BUFFER_SIZE: usize = 1600; + +/// Lock-free triple-buffer for audio oscilloscope capture. +/// +/// Uses three buffers: one being written by the audio thread, one ready for +/// reading, and one in transition. This allows lock-free concurrent access +/// from the audio callback (writer) and UI thread (reader). +pub struct ScopeCapture { + buffers: [Box<[f32; BUFFER_SIZE]>; 3], + write_idx: AtomicUsize, + write_buffer: AtomicUsize, + read_buffer: AtomicUsize, +} + +// SAFETY: All mutable access is through atomic operations or single-writer guarantee. +// The write methods are only called from one audio callback thread at a time. +unsafe impl Send for ScopeCapture {} +// SAFETY: Concurrent read/write is safe due to triple-buffering design. +// Writer and reader operate on different buffers, synchronized via atomics. +unsafe impl Sync for ScopeCapture {} + +impl ScopeCapture { + /// Creates a new scope capture with zeroed buffers. + pub fn new() -> Self { + Self { + buffers: [ + Box::new([0.0; BUFFER_SIZE]), + Box::new([0.0; BUFFER_SIZE]), + Box::new([0.0; BUFFER_SIZE]), + ], + write_idx: AtomicUsize::new(0), + write_buffer: AtomicUsize::new(0), + read_buffer: AtomicUsize::new(2), + } + } + + /// Pushes a stereo sample pair, converting to mono for display. + #[inline] + pub fn push_stereo(&self, left: f32, right: f32) { + let mono = (left + right) * 0.5; + self.push_mono(mono); + } + + /// Pushes a mono sample to the write buffer. + #[inline] + pub fn push_mono(&self, sample: f32) { + let buf_idx = self.write_buffer.load(Ordering::Relaxed); + let write_pos = self.write_idx.load(Ordering::Relaxed); + + let buf_ptr = self.buffers[buf_idx].as_ptr() as *mut f32; + // SAFETY: write_pos is always < BUFFER_SIZE, and only one writer exists. + unsafe { + *buf_ptr.add(write_pos) = sample; + } + + let next_pos = write_pos + 1; + if next_pos >= BUFFER_SIZE { + let next_buf = (buf_idx + 1) % 3; + self.read_buffer.store(buf_idx, Ordering::Release); + self.write_buffer.store(next_buf, Ordering::Relaxed); + self.write_idx.store(0, Ordering::Relaxed); + } else { + self.write_idx.store(next_pos, Ordering::Relaxed); + } + } + + /// Returns peak (min, max) pairs for waveform display. + pub fn read_peaks(&self, num_peaks: usize) -> Vec<(f32, f32)> { + if num_peaks == 0 { + return Vec::new(); + } + + let buf_idx = self.read_buffer.load(Ordering::Acquire); + let buf = &self.buffers[buf_idx]; + + let window = (BUFFER_SIZE / num_peaks).max(1); + buf.chunks(window) + .take(num_peaks) + .map(|chunk| { + chunk + .iter() + .fold((f32::MAX, f32::MIN), |(min, max), &s| (min.min(s), max.max(s))) + }) + .collect() + } + + /// Returns a copy of the current read buffer samples. + pub fn read_samples(&self) -> Vec { + let buf_idx = self.read_buffer.load(Ordering::Acquire); + self.buffers[buf_idx].to_vec() + } + + /// Returns the buffer size in samples. + pub const fn buffer_size() -> usize { + BUFFER_SIZE + } +} + +impl Default for ScopeCapture { + fn default() -> Self { + Self::new() + } +} diff --git a/doux-sova/src/time.rs b/doux-sova/src/time.rs new file mode 100644 index 0000000..bc6ef2e --- /dev/null +++ b/doux-sova/src/time.rs @@ -0,0 +1,34 @@ +//! Time synchronization between Sova and Doux. +//! +//! Sova uses microsecond timestamps (SyncTime) from its internal clock. +//! Doux uses seconds relative to engine start. This module converts between them. + +use sova_core::clock::SyncTime; + +/// Converts Sova timestamps to Doux engine time. +/// +/// Stores the initial sync time (engine start) and computes relative +/// offsets in seconds for incoming events. +pub struct TimeConverter { + /// Microsecond timestamp when the engine was started. + engine_start_micros: SyncTime, +} + +impl TimeConverter { + /// Creates a converter with the given initial sync time. + /// + /// Pass `clock.micros()` at engine startup. + pub fn new(initial_sync_time: SyncTime) -> Self { + Self { + engine_start_micros: initial_sync_time, + } + } + + /// Converts a Sova timetag to engine time in seconds. + /// + /// Returns the number of seconds since engine start. + pub fn sync_to_engine_time(&self, timetag: SyncTime) -> f64 { + let delta = timetag.saturating_sub(self.engine_start_micros); + (delta as f64) / 1_000_000.0 + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..95e39fa --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "doux", + "type": "module", + "private": true, + "scripts": { + "dev": "cd website && pnpm dev", + "build": "cd website && pnpm build", + "preview": "cd website && pnpm preview" + } +} diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..5796fc4 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,141 @@ +//! Audio device enumeration and stream creation utilities. +//! +//! Provides functions to list available audio devices and create audio streams +//! with specific configurations. + +use cpal::traits::{DeviceTrait, HostTrait}; +use cpal::{Device, Host, SupportedStreamConfig}; + +/// Information about an available audio device. +#[derive(Debug, Clone)] +pub struct AudioDeviceInfo { + pub name: String, + pub index: usize, + pub max_channels: u16, + pub is_default: bool, +} + +/// Returns the default CPAL host for the current platform. +pub fn default_host() -> Host { + cpal::default_host() +} + +/// Lists all available output audio devices. +pub fn list_output_devices() -> Vec { + let host = default_host(); + let default_name = host + .default_output_device() + .and_then(|d| d.name().ok()); + + let Ok(devices) = host.output_devices() else { + return Vec::new(); + }; + + devices + .enumerate() + .filter_map(|(index, device)| { + let name = device.name().ok()?; + let max_channels = device + .supported_output_configs() + .ok()? + .map(|c| c.channels()) + .max() + .unwrap_or(2); + let is_default = Some(&name) == default_name.as_ref(); + Some(AudioDeviceInfo { + name, + index, + max_channels, + is_default, + }) + }) + .collect() +} + +/// Lists all available input audio devices. +pub fn list_input_devices() -> Vec { + let host = default_host(); + let default_name = host + .default_input_device() + .and_then(|d| d.name().ok()); + + let Ok(devices) = host.input_devices() else { + return Vec::new(); + }; + + devices + .enumerate() + .filter_map(|(index, device)| { + let name = device.name().ok()?; + let max_channels = device + .supported_input_configs() + .ok()? + .map(|c| c.channels()) + .max() + .unwrap_or(2); + let is_default = Some(&name) == default_name.as_ref(); + Some(AudioDeviceInfo { + name, + index, + max_channels, + is_default, + }) + }) + .collect() +} + +/// Finds an output device by index or partial name match. +/// +/// If `spec` parses as a number, returns the device at that index. +/// Otherwise, performs a case-insensitive substring match on device names. +pub fn find_output_device(spec: &str) -> Option { + let host = default_host(); + let devices = host.output_devices().ok()?; + find_device_impl(devices, spec) +} + +/// Finds an input device by index or partial name match. +pub fn find_input_device(spec: &str) -> Option { + let host = default_host(); + let devices = host.input_devices().ok()?; + find_device_impl(devices, spec) +} + +fn find_device_impl(devices: I, spec: &str) -> Option +where + I: Iterator, +{ + let devices: Vec<_> = devices.collect(); + if let Ok(idx) = spec.parse::() { + return devices.into_iter().nth(idx); + } + let spec_lower = spec.to_lowercase(); + devices.into_iter().find(|d| { + d.name() + .map(|n| n.to_lowercase().contains(&spec_lower)) + .unwrap_or(false) + }) +} + +/// Returns the default output device. +pub fn default_output_device() -> Option { + default_host().default_output_device() +} + +/// Returns the default input device. +pub fn default_input_device() -> Option { + default_host().default_input_device() +} + +/// Gets the default output config for a device. +pub fn default_output_config(device: &Device) -> Option { + device.default_output_config().ok() +} + +/// Gets the maximum number of output channels supported by a device. +pub fn max_output_channels(device: &Device) -> u16 { + device + .supported_output_configs() + .map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2)) + .unwrap_or(2) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..45d88ec --- /dev/null +++ b/src/config.rs @@ -0,0 +1,66 @@ +//! Configuration types for the Doux audio engine. + +use std::path::PathBuf; + +/// Configuration for the Doux audio engine. +#[derive(Debug, Clone)] +pub struct DouxConfig { + /// Output device specification (name or index). None uses system default. + pub output_device: Option, + /// Input device specification (name or index). None uses system default. + pub input_device: Option, + /// Number of output channels (will be clamped to device maximum). + pub channels: u16, + /// Paths to sample directories for lazy loading. + pub sample_paths: Vec, + /// Audio buffer size in samples. None uses system default. + pub buffer_size: Option, +} + +impl Default for DouxConfig { + fn default() -> Self { + Self { + output_device: None, + input_device: None, + channels: 2, + sample_paths: Vec::new(), + buffer_size: None, + } + } +} + +impl DouxConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn with_output_device(mut self, device: impl Into) -> Self { + self.output_device = Some(device.into()); + self + } + + pub fn with_input_device(mut self, device: impl Into) -> Self { + self.input_device = Some(device.into()); + self + } + + pub fn with_channels(mut self, channels: u16) -> Self { + self.channels = channels; + self + } + + pub fn with_sample_path(mut self, path: impl Into) -> Self { + self.sample_paths.push(path.into()); + self + } + + pub fn with_sample_paths(mut self, paths: impl IntoIterator) -> Self { + self.sample_paths.extend(paths); + self + } + + pub fn with_buffer_size(mut self, size: u32) -> Self { + self.buffer_size = Some(size); + self + } +} diff --git a/src/effects/chorus.rs b/src/effects/chorus.rs new file mode 100644 index 0000000..45831a1 --- /dev/null +++ b/src/effects/chorus.rs @@ -0,0 +1,134 @@ +//! Multi-voice chorus effect with stereo modulation. +//! +//! Creates a shimmering, widened sound by mixing the dry signal with multiple +//! delayed copies whose delay times are modulated by LFOs. Each voice uses a +//! different LFO phase, and left/right channels are modulated in opposite +//! directions for stereo spread. +//! +//! # Signal Flow +//! +//! ```text +//! L+R → mono → delay line ─┬─ voice 0 (LFO phase 0°) ─┬─→ L +//! ├─ voice 1 (LFO phase 120°) ─┤ +//! └─ voice 2 (LFO phase 240°) ─┴─→ R +//! ``` +//! +//! The three voices are phase-offset by 120° to avoid reinforcement artifacts. +//! Left and right taps use opposite modulation polarity for stereo width. + +use crate::oscillator::Phasor; + +/// Delay buffer size in samples (~42ms at 48kHz). +const BUFFER_SIZE: usize = 2048; + +/// Number of chorus voices (phase-offset delay taps). +const VOICES: usize = 3; + +/// Multi-voice stereo chorus effect. +/// +/// Uses a circular delay buffer with three LFO-modulated tap points. +/// The LFOs are phase-offset by 1/3 cycle (120°) to create smooth, +/// non-pulsing modulation. +#[derive(Clone, Copy)] +pub struct Chorus { + /// Circular delay buffer (mono, power-of-2 for efficient wrapping). + buffer: [f32; BUFFER_SIZE], + /// Current write position in the delay buffer. + write_pos: usize, + /// Per-voice LFOs for delay time modulation. + lfo: [Phasor; VOICES], +} + +impl Default for Chorus { + fn default() -> Self { + let mut lfo = [Phasor::default(); VOICES]; + // Distribute LFO phases evenly: 0°, 120°, 240° + for (i, l) in lfo.iter_mut().enumerate() { + l.phase = i as f32 / VOICES as f32; + } + Self { + buffer: [0.0; BUFFER_SIZE], + write_pos: 0, + lfo, + } + } +} + +impl Chorus { + /// Processes one stereo sample through the chorus. + /// + /// # Parameters + /// + /// - `left`, `right`: Input stereo sample + /// - `rate`: LFO frequency in Hz (typical: 0.5-3.0) + /// - `depth`: Modulation intensity `[0.0, 1.0]` + /// - `delay_ms`: Base delay time in milliseconds (typical: 10-30) + /// - `sr`: Sample rate in Hz + /// - `isr`: Inverse sample rate (1.0 / sr) + /// + /// # Returns + /// + /// Stereo output `[left, right]` with 50/50 dry/wet mix (equal power). + pub fn process( + &mut self, + left: f32, + right: f32, + rate: f32, + depth: f32, + delay_ms: f32, + sr: f32, + isr: f32, + ) -> [f32; 2] { + let depth = depth.clamp(0.0, 1.0); + let mod_range = delay_ms * 0.8; + + // Sum to mono for delay line (common chorus technique) + let mono = (left + right) * 0.5; + self.buffer[self.write_pos] = mono; + + let mut out_l = 0.0_f32; + let mut out_r = 0.0_f32; + + let min_delay = 1.5; + let max_delay = 50.0_f32.min((BUFFER_SIZE as f32 - 2.0) * 1000.0 / sr); + + for v in 0..VOICES { + let lfo = self.lfo[v].sine(rate, isr); + + // Opposite modulation for L/R creates stereo width + let modulation = depth * mod_range * lfo; + let dly_l = (delay_ms + modulation).clamp(min_delay, max_delay); + let dly_r = (delay_ms - modulation).clamp(min_delay, max_delay); + + // Convert ms to samples + let samp_l = (dly_l * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0); + let samp_r = (dly_r * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0); + + // Linear interpolation for sub-sample accuracy + let pos_l = samp_l.floor() as usize; + let frac_l = samp_l - pos_l as f32; + let idx_l0 = (self.write_pos + BUFFER_SIZE - pos_l) & (BUFFER_SIZE - 1); + let idx_l1 = (self.write_pos + BUFFER_SIZE - pos_l - 1) & (BUFFER_SIZE - 1); + let tap_l = self.buffer[idx_l0] + frac_l * (self.buffer[idx_l1] - self.buffer[idx_l0]); + + let pos_r = samp_r.floor() as usize; + let frac_r = samp_r - pos_r as f32; + let idx_r0 = (self.write_pos + BUFFER_SIZE - pos_r) & (BUFFER_SIZE - 1); + let idx_r1 = (self.write_pos + BUFFER_SIZE - pos_r - 1) & (BUFFER_SIZE - 1); + let tap_r = self.buffer[idx_r0] + frac_r * (self.buffer[idx_r1] - self.buffer[idx_r0]); + + out_l += tap_l; + out_r += tap_r; + } + + self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1); + + // Average the voices + out_l /= VOICES as f32; + out_r /= VOICES as f32; + + // Equal-power mix: dry × 0.707 + wet × 0.707 + const MIX: f32 = std::f32::consts::FRAC_1_SQRT_2; + [mono * MIX + out_l * MIX, mono * MIX + out_r * MIX] + } +} diff --git a/src/effects/coarse.rs b/src/effects/coarse.rs new file mode 100644 index 0000000..5bd90db --- /dev/null +++ b/src/effects/coarse.rs @@ -0,0 +1,47 @@ +//! Sample rate reduction (bitcrusher-style decimation). +//! +//! Reduces the effective sample rate by holding each sample value for multiple +//! output samples, creating the characteristic "crunchy" lo-fi sound of early +//! samplers and video game consoles. +//! +//! # Example +//! +//! With `factor = 4` at 48kHz, the effective sample rate becomes 12kHz: +//! +//! ```text +//! Input: [a, b, c, d, e, f, g, h, ...] +//! Output: [a, a, a, a, e, e, e, e, ...] +//! ``` + +/// Sample-and-hold decimator for lo-fi effects. +/// +/// Holds input values for `factor` samples, reducing effective sample rate. +/// Often combined with bit depth reduction for full bitcrusher effects. +#[derive(Clone, Copy, Default)] +pub struct Coarse { + /// Currently held sample value. + hold: f32, + /// Sample counter (0 to factor-1). + t: usize, +} + +impl Coarse { + /// Processes one sample through the decimator. + /// + /// # Parameters + /// + /// - `input`: Input sample + /// - `factor`: Decimation factor (1.0 = bypass, 2.0 = half rate, etc.) + /// + /// # Returns + /// + /// The held sample value. Updates only when the internal counter wraps. + pub fn process(&mut self, input: f32, factor: f32) -> f32 { + let n = factor.max(1.0) as usize; + if self.t == 0 { + self.hold = input; + } + self.t = (self.t + 1) % n; + self.hold + } +} diff --git a/src/effects/comb.rs b/src/effects/comb.rs new file mode 100644 index 0000000..1b0bda5 --- /dev/null +++ b/src/effects/comb.rs @@ -0,0 +1,58 @@ +//! Comb filter with damping. +//! +//! Creates resonant peaks at `freq` and its harmonics by feeding delayed +//! signal back into itself. Damping applies a lowpass in the feedback path, +//! causing higher harmonics to decay faster (Karplus-Strong style). + +const BUFFER_SIZE: usize = 2048; + +/// Feedback comb filter with one-pole damping. +#[derive(Clone, Copy)] +pub struct Comb { + buffer: [f32; BUFFER_SIZE], + write_pos: usize, + damp_state: f32, +} + +impl Default for Comb { + fn default() -> Self { + Self { + buffer: [0.0; BUFFER_SIZE], + write_pos: 0, + damp_state: 0.0, + } + } +} + +impl Comb { + /// Processes one sample through the comb filter. + /// + /// - `freq`: Fundamental frequency (delay = 1/freq) + /// - `feedback`: Feedback amount `[-0.99, 0.99]` + /// - `damp`: High-frequency loss per iteration `[0.0, 1.0]` + /// + /// Returns the delayed signal (wet only). + pub fn process(&mut self, input: f32, freq: f32, feedback: f32, damp: f32, sr: f32) -> f32 { + let delay_samples = (sr / freq).clamp(1.0, (BUFFER_SIZE - 1) as f32); + let delay_int = delay_samples.floor() as usize; + let frac = delay_samples - delay_int as f32; + + // Linear interpolation for precise tuning + let idx0 = (self.write_pos + BUFFER_SIZE - delay_int) & (BUFFER_SIZE - 1); + let idx1 = (self.write_pos + BUFFER_SIZE - delay_int - 1) & (BUFFER_SIZE - 1); + let delayed = self.buffer[idx0] + frac * (self.buffer[idx1] - self.buffer[idx0]); + + let feedback = feedback.clamp(-0.99, 0.99); + let fb_signal = if damp > 0.0 { + self.damp_state = delayed * (1.0 - damp) + self.damp_state * damp; + self.damp_state + } else { + delayed + }; + + self.buffer[self.write_pos] = input + fb_signal * feedback; + self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1); + + delayed + } +} diff --git a/src/effects/crush.rs b/src/effects/crush.rs new file mode 100644 index 0000000..6f4455c --- /dev/null +++ b/src/effects/crush.rs @@ -0,0 +1,19 @@ +//! Bit depth reduction for lo-fi effects. +//! +//! Quantizes amplitude to fewer bits, creating the stepped distortion +//! characteristic of early digital audio. Pair with [`super::coarse`] for +//! full bitcrusher (sample rate + bit depth reduction). + +use crate::fastmath::exp2f; + +/// Reduces bit depth by quantizing to `2^(bits-1)` levels. +/// +/// - `bits = 16`: Near-transparent (CD quality) +/// - `bits = 8`: Classic 8-bit crunch +/// - `bits = 4`: Heavily degraded +/// - `bits = 1`: Square wave (extreme) +pub fn crush(input: f32, bits: f32) -> f32 { + let bits = bits.max(1.0); + let x = exp2f(bits - 1.0); + (input * x).round() / x +} diff --git a/src/effects/distort.rs b/src/effects/distort.rs new file mode 100644 index 0000000..2883097 --- /dev/null +++ b/src/effects/distort.rs @@ -0,0 +1,37 @@ +//! Waveshaping distortion effects. +//! +//! Three flavors of nonlinear distortion: +//! - [`distort`]: Soft saturation (tube-like warmth) +//! - [`fold`]: Wavefolding (complex harmonics) +//! - [`wrap`]: Phase wrapping (harsh, digital) + +use crate::fastmath::{expm1f, sinf}; + +/// Soft-knee saturation with adjustable drive. +/// +/// Uses `x / (1 + k|x|)` transfer function for smooth clipping. +/// Higher `amount` = more compression and harmonics. +pub fn distort(input: f32, amount: f32, postgain: f32) -> f32 { + let k = expm1f(amount); + ((1.0 + k) * input / (1.0 + k * input.abs())) * postgain +} + +/// Sine wavefolder. +/// +/// Folds the waveform back on itself using `sin(x × amount × π/2)`. +/// Creates rich harmonic content without hard clipping. +pub fn fold(input: f32, amount: f32) -> f32 { + sinf(input * amount * std::f32::consts::FRAC_PI_2) +} + +/// Wraps signal into `[-1, 1]` range using modulo. +/// +/// Creates harsh, digital-sounding distortion with discontinuities. +/// `wraps` controls how many times the signal can wrap. +pub fn wrap(input: f32, wraps: f32) -> f32 { + if wraps < 1.0 { + return input; + } + let x = input * (1.0 + wraps); + (x + 1.0).rem_euclid(2.0) - 1.0 +} diff --git a/src/effects/flanger.rs b/src/effects/flanger.rs new file mode 100644 index 0000000..17163f6 --- /dev/null +++ b/src/effects/flanger.rs @@ -0,0 +1,75 @@ +//! Flanger effect with LFO-modulated delay. +//! +//! Creates the characteristic "jet plane" sweep by mixing the input with a +//! short, modulated delay (0.5-10ms). Feedback intensifies the comb filtering. + +use crate::oscillator::Phasor; + +const BUFFER_SIZE: usize = 512; +const MIN_DELAY_MS: f32 = 0.5; +const MAX_DELAY_MS: f32 = 10.0; +const DELAY_RANGE_MS: f32 = MAX_DELAY_MS - MIN_DELAY_MS; + +/// Mono flanger with feedback. +#[derive(Clone, Copy)] +pub struct Flanger { + buffer: [f32; BUFFER_SIZE], + write_pos: usize, + lfo: Phasor, + feedback: f32, +} + +impl Default for Flanger { + fn default() -> Self { + Self { + buffer: [0.0; BUFFER_SIZE], + write_pos: 0, + lfo: Phasor::default(), + feedback: 0.0, + } + } +} + +impl Flanger { + /// Processes one sample. + /// + /// - `rate`: LFO speed in Hz (typical: 0.1-2.0) + /// - `depth`: Modulation amount `[0.0, 1.0]` (squared for smoother response) + /// - `feedback`: Resonance `[0.0, 0.95]` + /// + /// Returns 50/50 dry/wet mix. + pub fn process( + &mut self, + input: f32, + rate: f32, + depth: f32, + feedback: f32, + sr: f32, + isr: f32, + ) -> f32 { + let lfo_val = self.lfo.sine(rate, isr); + let depth_curve = depth * depth; + let delay_ms = MIN_DELAY_MS + depth_curve * DELAY_RANGE_MS * (lfo_val * 0.5 + 0.5); + + let delay_samples = (delay_ms * sr * 0.001).clamp(1.0, BUFFER_SIZE as f32 - 2.0); + + let read_pos_int = delay_samples.floor() as usize; + let frac = delay_samples - read_pos_int as f32; + + let read_index1 = (self.write_pos + BUFFER_SIZE - read_pos_int) & (BUFFER_SIZE - 1); + let read_index2 = (self.write_pos + BUFFER_SIZE - read_pos_int - 1) & (BUFFER_SIZE - 1); + + let delayed1 = self.buffer[read_index1]; + let delayed2 = self.buffer[read_index2]; + let delayed = delayed1 + frac * (delayed2 - delayed1); + + let feedback = feedback.clamp(0.0, 0.95); + + self.buffer[self.write_pos] = input + self.feedback * feedback; + self.write_pos = (self.write_pos + 1) & (BUFFER_SIZE - 1); + + self.feedback = delayed; + + input * 0.5 + delayed * 0.5 + } +} diff --git a/src/effects/lag.rs b/src/effects/lag.rs new file mode 100644 index 0000000..85e4bb7 --- /dev/null +++ b/src/effects/lag.rs @@ -0,0 +1,24 @@ +//! One-pole smoothing filter (slew limiter). +//! +//! Smooths abrupt parameter changes to prevent clicks and zipper noise. +//! Higher rate = slower response. + +/// One-pole lowpass for parameter smoothing. +#[derive(Clone, Copy, Default)] +pub struct Lag { + /// Current smoothed value. + pub s: f32, +} + +impl Lag { + /// Moves toward `input` at a rate controlled by `rate × lag_unit`. + /// + /// - `rate`: Smoothing factor (higher = slower) + /// - `lag_unit`: Scaling factor (typically sample-rate dependent) + #[inline] + pub fn update(&mut self, input: f32, rate: f32, lag_unit: f32) -> f32 { + let coeff = 1.0 / (rate * lag_unit).max(1.0); + self.s += coeff * (input - self.s); + self.s + } +} diff --git a/src/effects/mod.rs b/src/effects/mod.rs new file mode 100644 index 0000000..d6e5d65 --- /dev/null +++ b/src/effects/mod.rs @@ -0,0 +1,17 @@ +mod chorus; +mod coarse; +mod comb; +mod crush; +mod distort; +mod flanger; +mod lag; +mod phaser; + +pub use chorus::Chorus; +pub use coarse::Coarse; +pub use comb::Comb; +pub use crush::crush; +pub use distort::{distort, fold, wrap}; +pub use flanger::Flanger; +pub use lag::Lag; +pub use phaser::Phaser; diff --git a/src/effects/phaser.rs b/src/effects/phaser.rs new file mode 100644 index 0000000..999c185 --- /dev/null +++ b/src/effects/phaser.rs @@ -0,0 +1,50 @@ +//! Phaser effect using cascaded notch filters. +//! +//! Creates the sweeping, hollow sound by modulating two notch filters +//! with an LFO. The notches are offset by ~282 Hz for a richer effect. + +use crate::fastmath::exp2f; +use crate::filter::Biquad; +use crate::oscillator::Phasor; +use crate::types::FilterType; + +/// Frequency offset between the two notch filters (Hz). +const NOTCH_OFFSET: f32 = 282.0; + +/// Two-stage phaser with LFO modulation. +#[derive(Clone, Copy, Default)] +pub struct Phaser { + notch1: Biquad, + notch2: Biquad, + lfo: Phasor, +} + +impl Phaser { + /// Processes one sample. + /// + /// - `rate`: LFO speed in Hz + /// - `depth`: Notch resonance (higher = more pronounced, max ~0.95) + /// - `center`: Base frequency in Hz + /// - `sweep`: Modulation range in cents (1200 = ±1 octave) + pub fn process( + &mut self, + input: f32, + rate: f32, + depth: f32, + center: f32, + sweep: f32, + sr: f32, + isr: f32, + ) -> f32 { + let lfo_val = self.lfo.sine(rate, isr); + let q = 2.0 - (depth * 2.0).min(1.9); + let detune = exp2f(lfo_val * sweep * (1.0 / 1200.0)); + + let max_freq = sr * 0.45; + let freq1 = (center * detune).clamp(20.0, max_freq); + let freq2 = ((center + NOTCH_OFFSET) * detune).clamp(20.0, max_freq); + + let out = self.notch1.process(input, FilterType::Notch, freq1, q, sr); + self.notch2.process(out, FilterType::Notch, freq2, q, sr) + } +} diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..5620e13 --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,256 @@ +//! ADSR envelope generation for audio synthesis. +//! +//! This module provides a state-machine based ADSR (Attack, Decay, Sustain, Release) +//! envelope generator with configurable curve shapes. The envelope responds to gate +//! signals and produces amplitude values in the range `[0.0, 1.0]`. +//! +//! # Curve Shaping +//! +//! Attack and decay/release phases use exponential curves controlled by internal +//! parameters. Positive exponents create convex curves (slow start, fast finish), +//! while negative exponents create concave curves (fast start, slow finish). + +use crate::fastmath::powf; + +/// Attempt to scale the input `x` from range `[0, 1]` to range `[y0, y1]` with an exponent `exp`. +/// +/// Attempt because the expression `powf(1.0 - x, -exp)` can lead to a NaN when `exp` is greater than 1.0. +/// Using this function on 1.0 - x reverses the curve direction. +/// +/// - `exp > 0`: Convex curve (slow start, accelerates toward end) +/// - `exp < 0`: Concave curve (fast start, decelerates toward end) +/// - `exp == 0`: Linear interpolation +fn lerp(x: f32, y0: f32, y1: f32, exp: f32) -> f32 { + if x <= 0.0 { + return y0; + } + if x >= 1.0 { + return y1; + } + let curved = if exp == 0.0 { + x + } else if exp > 0.0 { + powf(x, exp) + } else { + 1.0 - powf(1.0 - x, -exp) + }; + y0 + (y1 - y0) * curved +} + +/// Current phase of the ADSR envelope state machine. +#[derive(Clone, Copy)] +pub enum AdsrState { + /// Envelope is inactive, outputting zero. + Off, + /// Rising from current value toward peak (1.0). + Attack, + /// Falling from peak toward sustain level. + Decay, + /// Holding at sustain level while gate remains high. + Sustain, + /// Falling from current value toward zero after gate release. + Release, +} + +/// State-machine ADSR envelope generator. +/// +/// Tracks envelope phase and timing internally. Call [`Adsr::update`] each sample +/// with the current time and gate signal to produce envelope values. +/// +/// # Curve Parameters +/// +/// Default curves use an exponent of `2.0` for attack (convex) and decay/release +/// (concave when negated internally), producing natural-sounding amplitude shapes. +#[derive(Clone, Copy)] +pub struct Adsr { + state: AdsrState, + start_time: f32, + start_val: f32, + attack_curve: f32, + decay_curve: f32, +} + +impl Default for Adsr { + fn default() -> Self { + Self { + state: AdsrState::Off, + start_time: 0.0, + start_val: 0.0, + attack_curve: 2.0, + decay_curve: 2.0, + } + } +} + +impl Adsr { + /// Returns `true` if the envelope is in the [`AdsrState::Off`] state. + pub fn is_off(&self) -> bool { + matches!(self.state, AdsrState::Off) + } + + /// Advances the envelope state machine and returns the current amplitude. + /// + /// The envelope responds to gate transitions: + /// - Gate going high (`> 0.0`) triggers attack from current value + /// - Gate going low (`<= 0.0`) triggers release from current value + /// + /// This allows retriggering during any phase without clicks, as the envelope + /// always starts from its current position rather than jumping to zero. + /// + /// # Parameters + /// + /// - `time`: Current time in seconds (must be monotonically increasing) + /// - `gate`: Gate signal (`> 0.0` = note on, `<= 0.0` = note off) + /// - `attack`: Attack duration in seconds + /// - `decay`: Decay duration in seconds + /// - `sustain`: Sustain level in range `[0.0, 1.0]` + /// - `release`: Release duration in seconds + /// + /// # Returns + /// + /// Envelope amplitude in range `[0.0, 1.0]`. + pub fn update( + &mut self, + time: f32, + gate: f32, + attack: f32, + decay: f32, + sustain: f32, + release: f32, + ) -> f32 { + match self.state { + AdsrState::Off => { + if gate > 0.0 { + self.state = AdsrState::Attack; + self.start_time = time; + self.start_val = 0.0; + } + 0.0 + } + AdsrState::Attack => { + let t = time - self.start_time; + if t > attack { + self.state = AdsrState::Decay; + self.start_time = time; + return 1.0; + } + lerp(t / attack, self.start_val, 1.0, self.attack_curve) + } + AdsrState::Decay => { + let t = time - self.start_time; + let val = lerp(t / decay, 1.0, sustain, -self.decay_curve); + if gate <= 0.0 { + self.state = AdsrState::Release; + self.start_time = time; + self.start_val = val; + return val; + } + if t > decay { + self.state = AdsrState::Sustain; + self.start_time = time; + return sustain; + } + val + } + AdsrState::Sustain => { + if gate <= 0.0 { + self.state = AdsrState::Release; + self.start_time = time; + self.start_val = sustain; + } + sustain + } + AdsrState::Release => { + let t = time - self.start_time; + if t > release { + self.state = AdsrState::Off; + return 0.0; + } + let val = lerp(t / release, self.start_val, 0.0, -self.decay_curve); + if gate > 0.0 { + self.state = AdsrState::Attack; + self.start_time = time; + self.start_val = val; + } + val + } + } + } +} + +/// Parsed envelope parameters with activation flag. +/// +/// Used to pass envelope configuration from pattern parsing to voice rendering. +/// The `active` field indicates whether the user explicitly specified any +/// envelope parameters, allowing voices to skip envelope processing when unused. +#[derive(Clone, Copy, Default)] +pub struct EnvelopeParams { + /// Overall envelope amplitude multiplier. + pub env: f32, + /// Attack time in seconds. + pub att: f32, + /// Decay time in seconds. + pub dec: f32, + /// Sustain level in range `[0.0, 1.0]`. + pub sus: f32, + /// Release time in seconds. + pub rel: f32, + /// Whether envelope parameters were explicitly provided. + pub active: bool, +} + +/// Constructs envelope parameters from optional user inputs. +/// +/// Applies sensible defaults and infers sustain level from context: +/// - If sustain is explicit, use it (clamped to `1.0`) +/// - If only attack is set, sustain defaults to `1.0` (full level after attack) +/// - If decay is set (with or without attack), sustain defaults to `0.0` +/// - Otherwise, sustain defaults to `1.0` +/// +/// When no parameters are provided, returns inactive defaults suitable for +/// bypassing envelope processing entirely. +/// +/// # Default Values +/// +/// | Parameter | Default | +/// |-----------|---------| +/// | `env` | `1.0` | +/// | `att` | `0.001` | +/// | `dec` | `0.0` | +/// | `sus` | `1.0` | +/// | `rel` | `0.005` | +pub fn init_envelope( + env: Option, + att: Option, + dec: Option, + sus: Option, + rel: Option, +) -> EnvelopeParams { + if env.is_none() && att.is_none() && dec.is_none() && sus.is_none() && rel.is_none() { + return EnvelopeParams { + env: 1.0, + att: 0.001, + dec: 0.0, + sus: 1.0, + rel: 0.005, + active: false, + }; + } + + let sus_val = match (sus, att, dec) { + (Some(s), _, _) => s.min(1.0), + (None, Some(_), None) => 1.0, + (None, None, Some(_)) => 0.0, + (None, Some(_), Some(_)) => 0.0, + _ => 1.0, + }; + + EnvelopeParams { + env: env.unwrap_or(1.0), + att: att.unwrap_or(0.001), + dec: dec.unwrap_or(0.0), + sus: sus_val, + rel: rel.unwrap_or(0.005), + active: true, + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..13abb86 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +//! Error types for the Doux audio engine. + +use std::fmt; + +/// Errors that can occur when working with the Doux audio engine. +#[derive(Debug)] +pub enum DouxError { + /// The specified audio device was not found. + DeviceNotFound(String), + /// No default audio device is available. + NoDefaultDevice, + /// Failed to create an audio stream. + StreamCreationFailed(String), + /// The requested channel count is invalid. + InvalidChannelCount(u16), + /// Failed to get device configuration. + DeviceConfigError(String), +} + +impl fmt::Display for DouxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DouxError::DeviceNotFound(name) => { + write!(f, "audio device not found: {name}") + } + DouxError::NoDefaultDevice => { + write!(f, "no default audio device available") + } + DouxError::StreamCreationFailed(msg) => { + write!(f, "failed to create audio stream: {msg}") + } + DouxError::InvalidChannelCount(count) => { + write!(f, "invalid channel count: {count}") + } + DouxError::DeviceConfigError(msg) => { + write!(f, "device configuration error: {msg}") + } + } + } +} + +impl std::error::Error for DouxError {} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..82f9e89 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,291 @@ +use crate::types::{midi2freq, DelayType, FilterSlope, LfoShape}; + +#[derive(Clone, Default, Debug)] +pub struct Event { + pub cmd: Option, + + // Timing + pub time: Option, + pub repeat: Option, + pub duration: Option, + pub gate: Option, + + // Voice control + pub voice: Option, + pub reset: Option, + pub orbit: Option, + + // Pitch + pub freq: Option, + pub detune: Option, + pub speed: Option, + pub glide: Option, + + // Source + pub sound: Option, + pub pw: Option, + pub spread: Option, + pub size: Option, + pub mult: Option, + pub warp: Option, + pub mirror: Option, + pub harmonics: Option, + pub timbre: Option, + pub morph: Option, + pub n: Option, + pub cut: Option, + pub begin: Option, + pub end: Option, + + // Web sample (WASM only - set by JavaScript) + pub file_pcm: Option, + pub file_frames: Option, + pub file_channels: Option, + pub file_freq: Option, + + // Gain + pub gain: Option, + pub postgain: Option, + pub velocity: Option, + pub pan: Option, + + // Gain envelope + pub attack: Option, + pub decay: Option, + pub sustain: Option, + pub release: Option, + + // Lowpass filter + pub lpf: Option, + pub lpq: Option, + pub lpe: Option, + pub lpa: Option, + pub lpd: Option, + pub lps: Option, + pub lpr: Option, + + // Highpass filter + pub hpf: Option, + pub hpq: Option, + pub hpe: Option, + pub hpa: Option, + pub hpd: Option, + pub hps: Option, + pub hpr: Option, + + // Bandpass filter + pub bpf: Option, + pub bpq: Option, + pub bpe: Option, + pub bpa: Option, + pub bpd: Option, + pub bps: Option, + pub bpr: Option, + + // Filter type + pub ftype: Option, + + // Pitch envelope + pub penv: Option, + pub patt: Option, + pub pdec: Option, + pub psus: Option, + pub prel: Option, + + // Vibrato + pub vib: Option, + pub vibmod: Option, + pub vibshape: Option, + + // FM synthesis + pub fm: Option, + pub fmh: Option, + pub fmshape: Option, + pub fme: Option, + pub fma: Option, + pub fmd: Option, + pub fms: Option, + pub fmr: Option, + + // AM + pub am: Option, + pub amdepth: Option, + pub amshape: Option, + + // Ring mod + pub rm: Option, + pub rmdepth: Option, + pub rmshape: Option, + + // Phaser + pub phaser: Option, + pub phaserdepth: Option, + pub phasersweep: Option, + pub phasercenter: Option, + + // Flanger + pub flanger: Option, + pub flangerdepth: Option, + pub flangerfeedback: Option, + + // Chorus + pub chorus: Option, + pub chorusdepth: Option, + pub chorusdelay: Option, + + // Comb filter + pub comb: Option, + pub combfreq: Option, + pub combfeedback: Option, + pub combdamp: Option, + + // Distortion + pub coarse: Option, + pub crush: Option, + pub fold: Option, + pub wrap: Option, + pub distort: Option, + pub distortvol: Option, + + // Delay + pub delay: Option, + pub delaytime: Option, + pub delayfeedback: Option, + pub delaytype: Option, + + // Reverb + pub verb: Option, + pub verbdecay: Option, + pub verbdamp: Option, + pub verbpredelay: Option, + pub verbdiff: Option, +} + +impl Event { + pub fn parse(input: &str) -> Self { + let mut event = Self::default(); + let tokens: Vec<&str> = input.trim().split('/').filter(|s| !s.is_empty()).collect(); + let mut i = 0; + while i + 1 < tokens.len() { + let key = tokens[i]; + let val = tokens[i + 1]; + match key { + "doux" | "dirt" => event.cmd = Some(val.to_string()), + "time" | "t" => event.time = val.parse().ok(), + "repeat" | "rep" => event.repeat = val.parse().ok(), + "duration" | "dur" | "d" => event.duration = val.parse().ok(), + "gate" => event.gate = val.parse().ok(), + "voice" => event.voice = val.parse::().ok().map(|f| f as usize), + "reset" => event.reset = Some(val == "1" || val == "true"), + "orbit" => event.orbit = val.parse::().ok().map(|f| f as usize), + "freq" => event.freq = val.parse().ok(), + "note" => event.freq = val.parse().ok().map(midi2freq), + "detune" => event.detune = val.parse().ok(), + "speed" => event.speed = val.parse().ok(), + "glide" => event.glide = val.parse().ok(), + "sound" | "s" => event.sound = Some(val.to_string()), + "pw" => event.pw = val.parse().ok(), + "spread" => event.spread = val.parse().ok(), + "size" => event.size = val.parse().ok(), + "mult" => event.mult = val.parse().ok(), + "warp" => event.warp = val.parse().ok(), + "mirror" => event.mirror = val.parse().ok(), + "harmonics" | "harm" => event.harmonics = val.parse().ok(), + "timbre" => event.timbre = val.parse().ok(), + "morph" => event.morph = val.parse().ok(), + "n" => event.n = val.parse::().ok().map(|f| f as usize), + "cut" => event.cut = val.parse::().ok().map(|f| f as usize), + "begin" => event.begin = val.parse().ok(), + "end" => event.end = val.parse().ok(), + "file_pcm" => event.file_pcm = val.parse().ok(), + "file_frames" => event.file_frames = val.parse().ok(), + "file_channels" => event.file_channels = val.parse::().ok().map(|f| f as u8), + "file_freq" => event.file_freq = val.parse().ok(), + "gain" => event.gain = val.parse().ok(), + "postgain" => event.postgain = val.parse().ok(), + "velocity" => event.velocity = val.parse().ok(), + "pan" => event.pan = val.parse().ok(), + "attack" => event.attack = val.parse().ok(), + "decay" => event.decay = val.parse().ok(), + "sustain" => event.sustain = val.parse().ok(), + "release" => event.release = val.parse().ok(), + "lpf" | "cutoff" => event.lpf = val.parse().ok(), + "lpq" | "resonance" => event.lpq = val.parse().ok(), + "lpe" | "lpenv" => event.lpe = val.parse().ok(), + "lpa" | "lpattack" => event.lpa = val.parse().ok(), + "lpd" | "lpdecay" => event.lpd = val.parse().ok(), + "lps" | "lpsustain" => event.lps = val.parse().ok(), + "lpr" | "lprelease" => event.lpr = val.parse().ok(), + "hpf" | "hcutoff" => event.hpf = val.parse().ok(), + "hpq" | "hresonance" => event.hpq = val.parse().ok(), + "hpe" | "hpenv" => event.hpe = val.parse().ok(), + "hpa" => event.hpa = val.parse().ok(), + "hpd" => event.hpd = val.parse().ok(), + "hps" => event.hps = val.parse().ok(), + "hpr" => event.hpr = val.parse().ok(), + "bpf" | "bandf" => event.bpf = val.parse().ok(), + "bpq" | "bandq" => event.bpq = val.parse().ok(), + "bpe" | "bpenv" => event.bpe = val.parse().ok(), + "bpa" | "bpattack" => event.bpa = val.parse().ok(), + "bpd" | "bpdecay" => event.bpd = val.parse().ok(), + "bps" | "bpsustain" => event.bps = val.parse().ok(), + "bpr" | "bprelease" => event.bpr = val.parse().ok(), + "ftype" => event.ftype = val.parse().ok(), + "penv" => event.penv = val.parse().ok(), + "patt" => event.patt = val.parse().ok(), + "pdec" => event.pdec = val.parse().ok(), + "psus" => event.psus = val.parse().ok(), + "prel" => event.prel = val.parse().ok(), + "vib" => event.vib = val.parse().ok(), + "vibmod" => event.vibmod = val.parse().ok(), + "vibshape" => event.vibshape = val.parse().ok(), + "fm" | "fmi" => event.fm = val.parse().ok(), + "fmh" => event.fmh = val.parse().ok(), + "fmshape" => event.fmshape = val.parse().ok(), + "fme" => event.fme = val.parse().ok(), + "fma" => event.fma = val.parse().ok(), + "fmd" => event.fmd = val.parse().ok(), + "fms" => event.fms = val.parse().ok(), + "fmr" => event.fmr = val.parse().ok(), + "am" => event.am = val.parse().ok(), + "amdepth" => event.amdepth = val.parse().ok(), + "amshape" => event.amshape = val.parse().ok(), + "rm" => event.rm = val.parse().ok(), + "rmdepth" => event.rmdepth = val.parse().ok(), + "rmshape" => event.rmshape = val.parse().ok(), + "phaser" | "phaserrate" => event.phaser = val.parse().ok(), + "phaserdepth" => event.phaserdepth = val.parse().ok(), + "phasersweep" => event.phasersweep = val.parse().ok(), + "phasercenter" => event.phasercenter = val.parse().ok(), + "flanger" | "flangerrate" => event.flanger = val.parse().ok(), + "flangerdepth" => event.flangerdepth = val.parse().ok(), + "flangerfeedback" => event.flangerfeedback = val.parse().ok(), + "chorus" | "chorusrate" => event.chorus = val.parse().ok(), + "chorusdepth" => event.chorusdepth = val.parse().ok(), + "chorusdelay" => event.chorusdelay = val.parse().ok(), + "comb" => event.comb = val.parse().ok(), + "combfreq" => event.combfreq = val.parse().ok(), + "combfeedback" => event.combfeedback = val.parse().ok(), + "combdamp" => event.combdamp = val.parse().ok(), + "coarse" => event.coarse = val.parse().ok(), + "crush" => event.crush = val.parse().ok(), + "fold" => event.fold = val.parse().ok(), + "wrap" => event.wrap = val.parse().ok(), + "distort" => event.distort = val.parse().ok(), + "distortvol" => event.distortvol = val.parse().ok(), + "delay" => event.delay = val.parse().ok(), + "delaytime" => event.delaytime = val.parse().ok(), + "delayfeedback" => event.delayfeedback = val.parse().ok(), + "delaytype" | "dtype" => event.delaytype = val.parse().ok(), + "verb" | "reverb" => event.verb = val.parse().ok(), + "verbdecay" => event.verbdecay = val.parse().ok(), + "verbdamp" => event.verbdamp = val.parse().ok(), + "verbpredelay" => event.verbpredelay = val.parse().ok(), + "verbdiff" => event.verbdiff = val.parse().ok(), + _ => {} + } + i += 2; + } + event + } +} diff --git a/src/fastmath.rs b/src/fastmath.rs new file mode 100644 index 0000000..8ce45a2 --- /dev/null +++ b/src/fastmath.rs @@ -0,0 +1,237 @@ +//! Fast approximations for common mathematical functions. +//! +//! This module provides SIMD-friendly, branch-minimal implementations of +//! transcendental functions optimized for audio synthesis. These trade some +//! accuracy for significant performance gains in tight DSP loops. +//! +//! # Accuracy +//! +//! | Function | Typical Error | +//! |----------|---------------| +//! | `exp2f` | < 0.1% | +//! | `log2f` | < 0.1% | +//! | `sinf` | < 1% | +//! | `pow10` | < 1% | +//! +//! # Implementation Notes +//! +//! The logarithm and exponential functions exploit IEEE 754 float bit layout, +//! extracting and manipulating exponent/mantissa fields directly. Trigonometric +//! functions use rational polynomial approximations. + +use std::f32::consts::{LOG2_10, LOG2_E, PI, SQRT_2}; + +/// Bit position of the exponent field in IEEE 754 single precision. +const F32_EXP_SHIFT: i32 = 23; + +/// Exponent bias for IEEE 754 single precision. +const F32_BIAS: i32 = 127; + +/// Fast base-2 logarithm approximation. +/// +/// Uses IEEE 754 bit manipulation to extract the exponent, then applies a +/// rational polynomial correction for the mantissa contribution. +/// +/// # Panics +/// +/// Does not panic, but returns meaningless results for `x <= 0`. +#[inline] +pub fn log2f(x: f32) -> f32 { + let bits = x.to_bits(); + let mantissa_bits = bits & ((1 << F32_EXP_SHIFT) - 1); + let biased_mantissa = f32::from_bits(mantissa_bits | ((F32_BIAS as u32 - 1) << F32_EXP_SHIFT)); + + let y = bits as f32 * (1.0 / (1 << F32_EXP_SHIFT) as f32); + y - 124.225_45 - 1.498_030_3 * biased_mantissa - 1.725_88 / (0.352_088_72 + biased_mantissa) +} + +/// Fast base-2 exponential approximation. +/// +/// Separates the integer and fractional parts of the exponent. The integer +/// part is computed via bit manipulation, while the fractional part uses a +/// Taylor-like polynomial expansion centered at 0.5. +#[inline] +pub fn exp2f(x: f32) -> f32 { + let xf = x.floor(); + let exp_bits = ((127 + xf as i32) as u32) << 23; + let ystep = f32::from_bits(exp_bits); + + let x1 = x - xf; + let xt = x1 - 0.5; + + const C1: f32 = 0.980_258_17; + const C2: f32 = 0.339_731_57; + const C3: f32 = 0.078_494_66; + const C4: f32 = 0.013_602_088; + + let ytaylor = SQRT_2 + xt * (C1 + xt * (C2 + xt * (C3 + xt * C4))); + + const M0: f32 = 0.999_944_3; + const M1: f32 = 1.000_031_2; + + ystep * ytaylor * (M0 + (M1 - M0) * x1) +} + +/// Fast power function: `x^y`. +/// +/// Computed as `2^(y * log2(x))` using fast approximations. +/// +/// # Special Cases +/// +/// - Returns `NAN` if `x < 0` +/// - Returns `0.0` if `x == 0` +/// - Returns `1.0` if `y == 0` +#[inline] +pub fn powf(x: f32, y: f32) -> f32 { + if x < 0.0 { + return f32::NAN; + } + if x == 0.0 { + return 0.0; + } + if y == 0.0 { + return 1.0; + } + exp2f(y * log2f(x)) +} + +/// Fast `e^x - 1` approximation. +/// +/// Useful for small `x` where `e^x` is close to 1 and direct subtraction +/// would lose precision (though this fast version doesn't preserve that property). +#[inline] +pub fn expm1f(x: f32) -> f32 { + exp2f(x * LOG2_E) - 1.0 +} + +/// Computes `0.5^x` (equivalently `2^(-x)`). +/// +/// Useful for exponential decay calculations where the half-life is the +/// natural unit. +#[inline] +pub fn pow1half(x: f32) -> f32 { + exp2f(-x) +} + +/// Fast `10^x` approximation. +/// +/// Useful for decibel conversions: `pow10(db / 20.0)` gives amplitude ratio. +#[inline] +pub fn pow10(x: f32) -> f32 { + exp2f(x * LOG2_10) +} + +/// Wraps angle to the range `[-π, π]`. +/// +/// Essential for maintaining phase coherence in oscillators over long +/// running times, preventing floating-point precision loss. +#[inline] +pub fn modpi(x: f32) -> f32 { + let mut x = x + PI; + x *= 0.5 / PI; + x -= x.floor(); + x *= 2.0 * PI; + x - PI +} + +/// Parabolic sine approximation. +/// +/// Very fast but lower accuracy than [`sinf`]. Uses a single parabola +/// fitted to match sine at 0, ±π/2, and ±π. +#[inline] +pub fn par_sinf(x: f32) -> f32 { + let x = modpi(x); + 0.405_284_73 * x * (PI - x.abs()) +} + +/// Parabolic cosine approximation. +/// +/// Phase-shifted [`par_sinf`]. +#[inline] +pub fn par_cosf(x: f32) -> f32 { + par_sinf(x + 0.5 * PI) +} + +/// Fast sine approximation using rational polynomial. +/// +/// Higher accuracy than [`par_sinf`] but still significantly faster than +/// `std::f32::sin`. Uses a Padé-like rational approximation. +#[inline] +pub fn sinf(x: f32) -> f32 { + let x = 4.0 * (x * (0.5 / PI) - (x * (0.5 / PI) + 0.75).floor() + 0.25).abs() - 1.0; + let x = x * (PI / 2.0); + + const C1: f32 = 1.0; + const C2: f32 = 445.0 / 12122.0; + const C3: f32 = -(2363.0 / 18183.0); + const C4: f32 = 601.0 / 872784.0; + const C5: f32 = 12671.0 / 4363920.0; + const C6: f32 = 121.0 / 16662240.0; + + let xx = x * x; + let num = x * (C1 + xx * (C3 + xx * C5)); + let denom = 1.0 + xx * (C2 + xx * (C4 + xx * C6)); + num / denom +} + +/// Fast cosine approximation. +/// +/// Phase-shifted [`sinf`]. +#[inline] +pub fn cosf(x: f32) -> f32 { + sinf(x + 0.5 * PI) +} + +/// Flush to zero: clamps small values to zero. +/// +/// Prevents denormalized floating-point numbers which can cause severe +/// performance degradation in audio processing loops on some architectures. +#[inline] +pub fn ftz(x: f32, limit: f32) -> f32 { + if x < limit && x > -limit { + 0.0 + } else { + x + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exp2f() { + for i in -10..10 { + let x = i as f32 * 0.5; + let fast = exp2f(x); + let std = 2.0_f32.powf(x); + assert!( + (fast - std).abs() < 0.001, + "exp2f({x}) = {fast} vs std {std}" + ); + } + } + + #[test] + fn test_sinf() { + for i in 0..20 { + let x = (i as f32 - 10.0) * 0.5; + let fast = sinf(x); + let std = x.sin(); + assert!((fast - std).abs() < 0.01, "sinf({x}) = {fast} vs std {std}"); + } + } + + #[test] + fn test_pow10() { + for i in -5..5 { + let x = i as f32 * 0.5; + let fast = pow10(x); + let std = 10.0_f32.powf(x); + assert!( + (fast - std).abs() / std < 0.01, + "pow10({x}) = {fast} vs std {std}" + ); + } + } +} diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..e641ec7 --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,261 @@ +//! Biquad filter implementation for audio processing. +//! +//! Provides a second-order IIR (biquad) filter with multiple filter types and +//! coefficient caching for efficient real-time parameter modulation. +//! +//! # Filter Types +//! +//! | Type | Description | +//! |-------------|--------------------------------------------------| +//! | Lowpass | Attenuates frequencies above cutoff | +//! | Highpass | Attenuates frequencies below cutoff | +//! | Bandpass | Passes frequencies near cutoff, attenuates rest | +//! | Notch | Attenuates frequencies near cutoff | +//! | Allpass | Passes all frequencies, shifts phase | +//! | Peaking | Boosts/cuts frequencies near cutoff | +//! | Lowshelf | Boosts/cuts frequencies below cutoff | +//! | Highshelf | Boosts/cuts frequencies above cutoff | +//! +//! # Coefficient Formulas +//! +//! Based on Robert Bristow-Johnson's Audio EQ Cookbook. + +use crate::fastmath::{par_cosf, par_sinf, pow10}; +use crate::types::FilterType; +use std::f32::consts::PI; + +/// Second-order IIR (biquad) filter with coefficient caching. +/// +/// Implements the standard Direct Form I difference equation: +/// +/// ```text +/// y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2] +/// ``` +/// +/// Coefficients are recalculated only when parameters change beyond a threshold, +/// reducing CPU overhead during smooth parameter automation. +#[derive(Clone, Copy)] +pub struct Biquad { + // Feedforward coefficients (numerator) + b0: f32, + b1: f32, + b2: f32, + // Feedback coefficients (denominator, negated) + a1: f32, + a2: f32, + // Input delay line + x1: f32, + x2: f32, + // Output delay line + y1: f32, + y2: f32, + // Cached parameters for change detection + cached_freq: f32, + cached_q: f32, + cached_gain: f32, + cached_filter_type: FilterType, +} + +impl Default for Biquad { + fn default() -> Self { + Self { + b0: 0.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + cached_freq: 0.0, + cached_q: 0.0, + cached_gain: 0.0, + cached_filter_type: FilterType::Lowpass, + } + } +} + +impl Biquad { + /// Checks if parameters have changed enough to warrant coefficient recalculation. + /// + /// Uses relative thresholds: 0.1% for frequency and Q, 0.01 dB for gain. + #[inline] + fn needs_recalc(&self, freq: f32, q: f32, gain: f32, filter_type: FilterType) -> bool { + if filter_type != self.cached_filter_type { + return true; + } + let freq_delta = (freq - self.cached_freq).abs() / self.cached_freq.max(1.0); + let q_delta = (q - self.cached_q).abs() / self.cached_q.max(0.1); + let gain_delta = (gain - self.cached_gain).abs(); + freq_delta > 0.001 || q_delta > 0.001 || gain_delta > 0.01 + } + + /// Processes a single sample through the filter. + /// + /// Convenience wrapper for [`Biquad::process_with_gain`] with `gain = 0.0`. + pub fn process( + &mut self, + input: f32, + filter_type: FilterType, + freq: f32, + q: f32, + sr: f32, + ) -> f32 { + self.process_with_gain(input, filter_type, freq, q, 0.0, sr) + } + + /// Processes a single sample with gain parameter for shelving/peaking filters. + /// + /// Recalculates coefficients only when parameters change significantly. + /// For lowpass and highpass, `q` is interpreted as resonance in dB. + /// For other types, `q` is the Q factor directly. + /// + /// # Parameters + /// + /// - `input`: Input sample + /// - `filter_type`: Type of filter response + /// - `freq`: Cutoff/center frequency in Hz + /// - `q`: Q factor or resonance (interpretation depends on filter type) + /// - `gain`: Boost/cut in dB (only used by peaking and shelving types) + /// - `sr`: Sample rate in Hz + pub fn process_with_gain( + &mut self, + input: f32, + filter_type: FilterType, + freq: f32, + q: f32, + gain: f32, + sr: f32, + ) -> f32 { + let freq = freq.clamp(1.0, sr * 0.45); + if self.needs_recalc(freq, q, gain, filter_type) { + let omega = 2.0 * PI * freq / sr; + let sin_omega = par_sinf(omega); + let cos_omega = par_cosf(omega); + + let q_linear = match filter_type { + FilterType::Lowpass | FilterType::Highpass => pow10(q / 20.0), + _ => q, + }; + let alpha = sin_omega / (2.0 * q_linear); + + let (b0, b1, b2, a0, a1, a2) = match filter_type { + FilterType::Lowpass => { + let b1 = 1.0 - cos_omega; + let b0 = b1 / 2.0; + let b2 = b0; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Highpass => { + let b0 = (1.0 + cos_omega) / 2.0; + let b1 = -(1.0 + cos_omega); + let b2 = b0; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Bandpass => { + let b0 = sin_omega / 2.0; + let b1 = 0.0; + let b2 = -b0; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Notch => { + let b0 = 1.0; + let b1 = -2.0 * cos_omega; + let b2 = 1.0; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Allpass => { + let b0 = 1.0 - alpha; + let b1 = -2.0 * cos_omega; + let b2 = 1.0 + alpha; + let a0 = 1.0 + alpha; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Peaking => { + let a = pow10(gain / 40.0); + let b0 = 1.0 + alpha * a; + let b1 = -2.0 * cos_omega; + let b2 = 1.0 - alpha * a; + let a0 = 1.0 + alpha / a; + let a1 = -2.0 * cos_omega; + let a2 = 1.0 - alpha / a; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Lowshelf => { + let a = pow10(gain / 40.0); + let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha; + let am1_cos = (a - 1.0) * cos_omega; + let ap1_cos = (a + 1.0) * cos_omega; + let b0 = a * ((a + 1.0) - am1_cos + sqrt2_a_alpha); + let b1 = 2.0 * a * ((a - 1.0) - ap1_cos); + let b2 = a * ((a + 1.0) - am1_cos - sqrt2_a_alpha); + let a0 = (a + 1.0) + am1_cos + sqrt2_a_alpha; + let a1 = -2.0 * ((a - 1.0) + ap1_cos); + let a2 = (a + 1.0) + am1_cos - sqrt2_a_alpha; + (b0, b1, b2, a0, a1, a2) + } + FilterType::Highshelf => { + let a = pow10(gain / 40.0); + let sqrt2_a_alpha = 2.0 * a.sqrt() * alpha; + let am1_cos = (a - 1.0) * cos_omega; + let ap1_cos = (a + 1.0) * cos_omega; + let b0 = a * ((a + 1.0) + am1_cos + sqrt2_a_alpha); + let b1 = -2.0 * a * ((a - 1.0) + ap1_cos); + let b2 = a * ((a + 1.0) + am1_cos - sqrt2_a_alpha); + let a0 = (a + 1.0) - am1_cos + sqrt2_a_alpha; + let a1 = 2.0 * ((a - 1.0) - ap1_cos); + let a2 = (a + 1.0) - am1_cos - sqrt2_a_alpha; + (b0, b1, b2, a0, a1, a2) + } + }; + + self.b0 = b0 / a0; + self.b1 = b1 / a0; + self.b2 = b2 / a0; + self.a1 = a1 / a0; + self.a2 = a2 / a0; + + self.cached_freq = freq; + self.cached_q = q; + self.cached_gain = gain; + self.cached_filter_type = filter_type; + } + + let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 + - self.a2 * self.y2; + + self.x2 = self.x1; + self.x1 = input; + self.y2 = self.y1; + self.y1 = output; + + output + } +} + +/// Multi-stage filter state for cascaded biquad processing. +/// +/// Contains up to 4 biquad stages for steeper filter slopes (up to 48 dB/octave). +#[derive(Clone, Copy, Default)] +pub struct FilterState { + /// Current cutoff frequency in Hz. + pub cutoff: f32, + /// Cascaded biquad filter stages. + pub biquads: [Biquad; 4], +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7774867 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,676 @@ +#[cfg(feature = "native")] +pub mod audio; +#[cfg(feature = "native")] +pub mod config; +pub mod effects; +pub mod envelope; +#[cfg(feature = "native")] +pub mod error; +pub mod event; +pub mod fastmath; +pub mod filter; +#[cfg(feature = "native")] +pub mod loader; +pub mod noise; +pub mod orbit; +#[cfg(feature = "native")] +pub mod osc; +pub mod oscillator; +pub mod plaits; +pub mod sample; +pub mod schedule; +#[cfg(feature = "native")] +pub mod telemetry; +pub mod types; +pub mod voice; +#[cfg(target_arch = "wasm32")] +mod wasm; + +use envelope::init_envelope; +use event::Event; +use orbit::{EffectParams, Orbit}; +use sample::{FileSource, SampleEntry, SampleInfo, SamplePool, WebSampleSource}; +use schedule::Schedule; +#[cfg(feature = "native")] +use telemetry::EngineMetrics; +use types::{DelayType, Source, BLOCK_SIZE, CHANNELS, MAX_ORBITS, MAX_VOICES}; +use voice::{Voice, VoiceParams}; + +pub struct Engine { + pub sr: f32, + pub isr: f32, + pub voices: Vec, + pub active_voices: usize, + pub orbits: Vec, + pub schedule: Schedule, + pub time: f64, + pub tick: u64, + pub output_channels: usize, + pub output: Vec, + // Sample storage + pub sample_pool: SamplePool, + pub samples: Vec, + pub sample_index: Vec, + // Default orbit params (used when voice doesn't specify) + pub effect_params: EffectParams, + // Telemetry (native only) + #[cfg(feature = "native")] + pub metrics: EngineMetrics, +} + +impl Engine { + pub fn new(sample_rate: f32) -> Self { + Self::new_with_channels(sample_rate, CHANNELS) + } + + pub fn new_with_channels(sample_rate: f32, output_channels: usize) -> Self { + let mut orbits = Vec::with_capacity(MAX_ORBITS); + for _ in 0..MAX_ORBITS { + orbits.push(Orbit::new(sample_rate)); + } + + Self { + sr: sample_rate, + isr: 1.0 / sample_rate, + voices: vec![Voice::default(); MAX_VOICES], + active_voices: 0, + orbits, + schedule: Schedule::new(), + time: 0.0, + tick: 0, + output_channels, + output: vec![0.0; BLOCK_SIZE * output_channels], + sample_pool: SamplePool::new(), + samples: Vec::with_capacity(256), + sample_index: Vec::new(), + effect_params: EffectParams { + delay_time: 0.333, + delay_feedback: 0.6, + delay_type: DelayType::Standard, + verb_decay: 0.75, + verb_damp: 0.95, + verb_predelay: 0.1, + verb_diff: 0.7, + comb_freq: 220.0, + comb_feedback: 0.9, + comb_damp: 0.1, + }, + #[cfg(feature = "native")] + metrics: EngineMetrics::default(), + } + } + + pub fn load_sample(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option { + let info = self.sample_pool.add(samples, channels, freq)?; + let idx = self.samples.len(); + self.samples.push(info); + Some(idx) + } + + /// Look up sample by name (e.g., "wave_tek") and n (e.g., 0 for "wave_tek/0") + /// n wraps around using modulo if it exceeds the folder count + fn find_sample_index(&self, name: &str, n: usize) -> Option { + let prefix = format!("{name}/"); + let count = self + .sample_index + .iter() + .filter(|e| e.name.starts_with(&prefix)) + .count(); + if count == 0 { + return None; + } + let wrapped_n = n % count; + let target = format!("{name}/{wrapped_n}"); + self.sample_index.iter().position(|e| e.name == target) + } + + /// Get a loaded sample index, loading lazily if needed (native only) + fn get_or_load_sample(&mut self, name: &str, n: usize) -> Option { + // First check if this is a direct index into already-loaded samples (WASM path) + // For WASM, treat `name` as numeric index if sample_index is empty + if self.sample_index.is_empty() { + let idx: usize = name.parse().ok()?; + if idx < self.samples.len() { + return Some(idx); + } + return None; + } + + // Find the sample in the index by name + let index_idx = self.find_sample_index(name, n)?; + + // If already loaded, return the loaded index + if let Some(loaded_idx) = self.sample_index[index_idx].loaded { + return Some(loaded_idx); + } + + // Load the sample now (native only) + #[cfg(feature = "native")] + { + let path = self.sample_index[index_idx].path.clone(); + match loader::load_sample_file(self, &path) { + Ok(loaded_idx) => { + self.sample_index[index_idx].loaded = Some(loaded_idx); + Some(loaded_idx) + } + Err(e) => { + eprintln!( + "Failed to load sample {}: {e}", + self.sample_index[index_idx].name + ); + None + } + } + } + #[cfg(not(feature = "native"))] + { + None + } + } + + pub fn evaluate(&mut self, input: &str) -> Option { + let event = Event::parse(input); + + // Default to "play" if no explicit command - matches dough's JS wrapper behavior + let cmd = event.cmd.as_deref().unwrap_or("play"); + + match cmd { + "play" => self.play_event(event), + "hush" => { + self.hush(); + None + } + "panic" => { + self.panic(); + None + } + "reset" => { + self.panic(); + self.schedule.clear(); + self.time = 0.0; + self.tick = 0; + None + } + "release" => { + if let Some(v) = event.voice { + if v < self.active_voices { + self.voices[v].params.gate = 0.0; + } + } + None + } + "hush_endless" => { + for i in 0..self.active_voices { + if self.voices[i].params.duration.is_none() { + self.voices[i].params.gate = 0.0; + } + } + None + } + "reset_time" => { + self.time = 0.0; + self.tick = 0; + None + } + "reset_schedule" => { + self.schedule.clear(); + None + } + _ => None, + } + } + + fn play_event(&mut self, event: Event) -> Option { + if event.time.is_some() { + // ALL events with time go to schedule (like dough.c) + // This ensures repeat works correctly for time=0 events + self.schedule.push(event); + return None; + } + self.process_event(&event) + } + + pub fn play(&mut self, params: VoiceParams) -> Option { + if self.active_voices >= MAX_VOICES { + return None; + } + let i = self.active_voices; + self.voices[i] = Voice::default(); + self.voices[i].params = params; + self.voices[i].sr = self.sr; + self.active_voices += 1; + Some(i) + } + + /// Process an event, handling voice selection like dough.c's process_engine_event() + fn process_event(&mut self, event: &Event) -> Option { + // Cut group: release any voices in the same cut group + if let Some(cut) = event.cut { + for i in 0..self.active_voices { + if self.voices[i].params.cut == Some(cut) { + self.voices[i].params.gate = 0.0; + } + } + } + + let (voice_idx, is_new_voice) = if let Some(v) = event.voice { + if v < self.active_voices { + // Voice exists - reuse it + (v, false) + } else { + // Voice index out of range - allocate new + if self.active_voices >= MAX_VOICES { + return None; + } + let i = self.active_voices; + self.active_voices += 1; + (i, true) + } + } else { + // No voice specified - allocate new + if self.active_voices >= MAX_VOICES { + return None; + } + let i = self.active_voices; + self.active_voices += 1; + (i, true) + }; + + let should_reset = is_new_voice || event.reset.unwrap_or(false); + + if should_reset { + self.voices[voice_idx] = Voice::default(); + self.voices[voice_idx].sr = self.sr; + // Initialize glide_lag to target freq to prevent glide from 0 + if let Some(freq) = event.freq { + self.voices[voice_idx].glide_lag.s = freq; + } + } + + // Update voice params (only the ones explicitly set in event) + self.update_voice_params(voice_idx, event); + + Some(voice_idx) + } + + /// Update voice params - only updates fields that are explicitly set in the event + fn update_voice_params(&mut self, idx: usize, event: &Event) { + macro_rules! copy_opt { + ($src:expr, $dst:expr, $($field:ident),+ $(,)?) => { + $(if let Some(val) = $src.$field { $dst.$field = val; })+ + }; + } + macro_rules! copy_opt_some { + ($src:expr, $dst:expr, $($field:ident),+ $(,)?) => { + $(if let Some(val) = $src.$field { $dst.$field = Some(val); })+ + }; + } + // Resolve sound/sample first (before borrowing voice) + // If sound parses as a Source, use it; otherwise treat as sample folder name + let (parsed_source, loaded_sample) = if let Some(ref sound_str) = event.sound { + if let Ok(source) = sound_str.parse::() { + (Some(source), None) + } else { + // Treat as sample folder name + let sample = self.get_or_load_sample(sound_str, event.n.unwrap_or(0)); + (None, sample) + } + } else { + (None, None) + }; + + let v = &mut self.voices[idx]; + + // --- Pitch --- + copy_opt!(event, v.params, freq, detune, speed); + copy_opt_some!(event, v.params, glide); + + // --- Source --- + if let Some(source) = parsed_source { + v.params.sound = source; + } + copy_opt!(event, v.params, pw, spread); + if let Some(size) = event.size { + v.params.shape.size = size.min(256); + } + if let Some(mult) = event.mult { + v.params.shape.mult = mult.clamp(0.25, 16.0); + } + if let Some(warp) = event.warp { + v.params.shape.warp = warp.clamp(-1.0, 1.0); + } + if let Some(mirror) = event.mirror { + v.params.shape.mirror = mirror.clamp(0.0, 1.0); + } + if let Some(harmonics) = event.harmonics { + v.params.harmonics = harmonics.clamp(0.01, 0.999); + } + if let Some(timbre) = event.timbre { + v.params.timbre = timbre.clamp(0.01, 0.999); + } + if let Some(morph) = event.morph { + v.params.morph = morph.clamp(0.01, 0.999); + } + copy_opt_some!(event, v.params, cut); + + // Sample playback (native) + if let Some(sample_idx) = loaded_sample { + v.params.sound = Source::Sample; + let begin = event.begin.unwrap_or(0.0); + let end = event.end.unwrap_or(1.0); + v.file_source = Some(FileSource::new(sample_idx, begin, end)); + } else if event.begin.is_some() || event.end.is_some() { + // Update begin/end on existing file_source + if let Some(ref mut fs) = v.file_source { + if let Some(begin) = event.begin { + fs.begin = begin.clamp(0.0, 1.0); + } + if let Some(end) = event.end { + fs.end = end.clamp(fs.begin, 1.0); + } + } + } + + // Web sample playback (WASM - set by JavaScript) + if let (Some(offset), Some(frames)) = (event.file_pcm, event.file_frames) { + v.params.sound = Source::WebSample; + v.web_sample = Some(WebSampleSource::new( + SampleInfo { + offset, + frames: frames as u32, + channels: event.file_channels.unwrap_or(1), + freq: event.file_freq.unwrap_or(65.406), + }, + event.begin.unwrap_or(0.0), + event.end.unwrap_or(1.0), + )); + } + + // --- Gain --- + copy_opt!(event, v.params, gain, postgain, velocity, pan, gate); + copy_opt_some!(event, v.params, duration); + + // --- Gain Envelope --- + let gain_env = init_envelope( + None, + event.attack, + event.decay, + event.sustain, + event.release, + ); + if gain_env.active { + v.params.attack = gain_env.att; + v.params.decay = gain_env.dec; + v.params.sustain = gain_env.sus; + v.params.release = gain_env.rel; + } + + // --- Filters --- + // Macro to apply envelope params (env amount + ADSR) to a target + macro_rules! apply_env { + ($src:expr, $dst:expr, $e:ident, $a:ident, $d:ident, $s:ident, $r:ident, $active:ident) => { + let env = init_envelope($src.$e, $src.$a, $src.$d, $src.$s, $src.$r); + if env.active { + $dst.$e = env.env; + $dst.$a = env.att; + $dst.$d = env.dec; + $dst.$s = env.sus; + $dst.$r = env.rel; + $dst.$active = true; + } + }; + } + + copy_opt_some!(event, v.params, lpf); + copy_opt!(event, v.params, lpq); + apply_env!(event, v.params, lpe, lpa, lpd, lps, lpr, lp_env_active); + + copy_opt_some!(event, v.params, hpf); + copy_opt!(event, v.params, hpq); + apply_env!(event, v.params, hpe, hpa, hpd, hps, hpr, hp_env_active); + + copy_opt_some!(event, v.params, bpf); + copy_opt!(event, v.params, bpq); + apply_env!(event, v.params, bpe, bpa, bpd, bps, bpr, bp_env_active); + + copy_opt!(event, v.params, ftype); + + // --- Modulation --- + apply_env!( + event, + v.params, + penv, + patt, + pdec, + psus, + prel, + pitch_env_active + ); + copy_opt!(event, v.params, vib, vibmod, vibshape); + copy_opt!(event, v.params, fm, fmh, fmshape); + apply_env!(event, v.params, fme, fma, fmd, fms, fmr, fm_env_active); + copy_opt!(event, v.params, am, amdepth, amshape); + copy_opt!(event, v.params, rm, rmdepth, rmshape); + + // --- Effects --- + copy_opt!( + event, + v.params, + phaser, + phaserdepth, + phasersweep, + phasercenter + ); + copy_opt!(event, v.params, flanger, flangerdepth, flangerfeedback); + copy_opt!(event, v.params, chorus, chorusdepth, chorusdelay); + copy_opt!(event, v.params, comb, combfreq, combfeedback, combdamp); + copy_opt_some!(event, v.params, coarse, crush, fold, wrap, distort); + copy_opt!(event, v.params, distortvol); + + // --- Sends --- + copy_opt!( + event, + v.params, + orbit, + delay, + delaytime, + delayfeedback, + delaytype + ); + copy_opt!( + event, + v.params, + verb, + verbdecay, + verbdamp, + verbpredelay, + verbdiff + ); + } + + fn free_voice(&mut self, i: usize) { + if self.active_voices > 0 { + self.active_voices -= 1; + self.voices.swap(i, self.active_voices); + } + } + + fn process_schedule(&mut self) { + loop { + // O(1) early-exit: check only the first (earliest) event + let t = match self.schedule.peek_time() { + Some(t) if t <= self.time => t, + _ => return, + }; + + let diff = self.time - t; + let mut event = self.schedule.pop_front().unwrap(); + + // Fire only if event is fresh (within 1ms) - matches dough.c WASM + // Old events are silently rescheduled to catch up + if diff < 0.001 { + self.process_event(&event); + } + + // Reschedule repeating events (re-insert in sorted order) + if let Some(rep) = event.repeat { + event.time = Some(t + rep as f64); + self.schedule.push(event); + } + // Loop continues for catch-up behavior + } + } + + pub fn gen_sample( + &mut self, + output: &mut [f32], + sample_idx: usize, + web_pcm: &[f32], + live_input: &[f32], + ) { + let base_idx = sample_idx * self.output_channels; + let num_pairs = self.output_channels / 2; + + for c in 0..self.output_channels { + output[base_idx + c] = 0.0; + } + + // Clear orbit sends + for orbit in &mut self.orbits { + orbit.clear_sends(); + } + + // Process voices - matches dough.c behavior exactly: + // When a voice dies, it's freed immediately and the loop continues, + // which means the swapped-in voice (from the end) gets skipped this frame. + let isr = self.isr; + let num_orbits = self.orbits.len(); + + let mut i = 0; + while i < self.active_voices { + // Reborrow for each iteration to allow free_voice during loop + let pool = self.sample_pool.data.as_slice(); + let samples = self.samples.as_slice(); + + let alive = self.voices[i].process(isr, pool, samples, web_pcm, sample_idx, live_input); + if !alive { + self.free_voice(i); + // Match dough.c: increment i, skipping the swapped-in voice + i += 1; + continue; + } + + let orbit_idx = self.voices[i].params.orbit % num_orbits; + let out_pair = orbit_idx % num_pairs; + let pair_offset = out_pair * 2; + + output[base_idx + pair_offset] += self.voices[i].ch[0]; + output[base_idx + pair_offset + 1] += self.voices[i].ch[1]; + + // Add to orbit sends + if self.voices[i].params.delay > 0.0 { + for c in 0..CHANNELS { + self.orbits[orbit_idx] + .add_delay_send(c, self.voices[i].ch[c] * self.voices[i].params.delay); + } + // Update orbit delay params from voice + self.effect_params.delay_time = self.voices[i].params.delaytime; + self.effect_params.delay_feedback = self.voices[i].params.delayfeedback; + self.effect_params.delay_type = self.voices[i].params.delaytype; + } + if self.voices[i].params.verb > 0.0 { + for c in 0..CHANNELS { + self.orbits[orbit_idx] + .add_verb_send(c, self.voices[i].ch[c] * self.voices[i].params.verb); + } + // Update orbit verb params from voice + self.effect_params.verb_decay = self.voices[i].params.verbdecay; + self.effect_params.verb_damp = self.voices[i].params.verbdamp; + self.effect_params.verb_predelay = self.voices[i].params.verbpredelay; + self.effect_params.verb_diff = self.voices[i].params.verbdiff; + } + if self.voices[i].params.comb > 0.0 { + for c in 0..CHANNELS { + self.orbits[orbit_idx] + .add_comb_send(c, self.voices[i].ch[c] * self.voices[i].params.comb); + } + // Update orbit comb params from voice + self.effect_params.comb_freq = self.voices[i].params.combfreq; + self.effect_params.comb_feedback = self.voices[i].params.combfeedback; + self.effect_params.comb_damp = self.voices[i].params.combdamp; + } + + i += 1; + } + + for (orbit_idx, orbit) in self.orbits.iter_mut().enumerate() { + orbit.process(&self.effect_params); + + let out_pair = orbit_idx % num_pairs; + let pair_offset = out_pair * 2; + output[base_idx + pair_offset] += + orbit.delay_out[0] + orbit.verb_out[0] + orbit.comb_out[0]; + output[base_idx + pair_offset + 1] += + orbit.delay_out[1] + orbit.verb_out[1] + orbit.comb_out[1]; + } + + for c in 0..self.output_channels { + output[base_idx + c] = (output[base_idx + c] * 0.5).clamp(-1.0, 1.0); + } + } + + pub fn process_block(&mut self, output: &mut [f32], web_pcm: &[f32], live_input: &[f32]) { + #[cfg(feature = "native")] + let start = std::time::Instant::now(); + + let samples = output.len() / self.output_channels; + for i in 0..samples { + self.process_schedule(); + self.tick += 1; + self.time = self.tick as f64 / self.sr as f64; + self.gen_sample(output, i, web_pcm, live_input); + } + + #[cfg(feature = "native")] + { + use std::sync::atomic::Ordering; + let elapsed_ns = start.elapsed().as_nanos() as u64; + self.metrics.load.record_sample(elapsed_ns); + self.metrics + .active_voices + .store(self.active_voices as u32, Ordering::Relaxed); + self.metrics + .peak_voices + .fetch_max(self.active_voices as u32, Ordering::Relaxed); + self.metrics + .schedule_depth + .store(self.schedule.len() as u32, Ordering::Relaxed); + } + } + + pub fn dsp(&mut self) { + let mut output = std::mem::take(&mut self.output); + self.process_block(&mut output, &[], &[]); + self.output = output; + } + + pub fn dsp_with_web_pcm(&mut self, web_pcm: &[f32], live_input: &[f32]) { + let mut output = std::mem::take(&mut self.output); + self.process_block(&mut output, web_pcm, live_input); + self.output = output; + } + + pub fn get_time(&self) -> f64 { + self.time + } + + pub fn hush(&mut self) { + for i in 0..self.active_voices { + self.voices[i].params.gate = 0.0; + } + } + + pub fn panic(&mut self) { + self.active_voices = 0; + } +} diff --git a/src/loader.rs b/src/loader.rs new file mode 100644 index 0000000..88b5608 --- /dev/null +++ b/src/loader.rs @@ -0,0 +1,266 @@ +//! Audio sample loading and directory scanning. +//! +//! Handles discovery and decoding of audio files into the engine's sample pool. +//! Supports common audio formats via Symphonia: WAV, MP3, OGG, FLAC, AAC, M4A. +//! +//! # Directory Structure +//! +//! The scanner expects samples organized as: +//! +//! ```text +//! samples/ +//! ├── kick.wav → named "kick" +//! ├── snare.wav → named "snare" +//! └── hats/ → folder creates numbered entries +//! ├── closed.wav → named "hats/0" +//! ├── open.wav → named "hats/1" +//! └── pedal.wav → named "hats/2" +//! ``` +//! +//! Files within folders are sorted alphabetically and assigned sequential indices. +//! +//! # Lazy Loading +//! +//! [`scan_samples_dir`] only builds the index without decoding audio data. +//! Actual decoding happens on first use via [`load_sample_file`], keeping +//! startup fast even with large sample libraries. + +use std::fs::File; +use std::path::Path; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use crate::sample::SampleEntry; +use crate::Engine; + +/// Default base frequency assigned to loaded samples (C2 = 65.406 Hz). +/// +/// Samples are assumed to be pitched at this frequency unless overridden. +/// Used for pitch-shifting calculations during playback. +const DEFAULT_BASE_FREQ: f32 = 65.406; + +/// Supported audio file extensions. +const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "ogg", "flac", "aac", "m4a"]; + +/// Checks if a file path has a supported audio extension. +fn is_audio_file(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| { + AUDIO_EXTENSIONS + .iter() + .any(|ext| e.eq_ignore_ascii_case(ext)) + }) +} + +/// Scans a directory for audio samples without loading audio data. +/// +/// Builds an index of [`SampleEntry`] with paths and names. Audio data +/// remains unloaded (`loaded: None`) until explicitly requested. +/// +/// Top-level audio files are named by their stem (filename without extension). +/// Subdirectories create grouped entries named `folder/index` where index +/// is the alphabetical position within that folder. +/// +/// Prints a summary of discovered samples and folders to stdout. +pub fn scan_samples_dir(dir: &Path) -> Vec { + let mut entries = Vec::new(); + let mut folder_count = 0; + + let items = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("Failed to read directory {}: {e}", dir.display()); + return entries; + } + }; + + let mut paths: Vec<_> = items.filter_map(|e| e.ok()).map(|e| e.path()).collect(); + paths.sort(); + + for item in paths { + if item.is_dir() { + let folder_name = item + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let sub_entries = match std::fs::read_dir(&item) { + Ok(e) => e, + Err(_) => continue, + }; + + let mut files: Vec<_> = sub_entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| is_audio_file(p)) + .collect(); + + files.sort(); + + if !files.is_empty() { + folder_count += 1; + } + + for (i, path) in files.into_iter().enumerate() { + let name = format!("{folder_name}/{i}"); + entries.push(SampleEntry { + path, + name, + loaded: None, + }); + } + } else if is_audio_file(&item) { + let name = item + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + entries.push(SampleEntry { + path: item, + name, + loaded: None, + }); + } + } + + if !entries.is_empty() { + println!( + " Found {} samples in {} folders", + entries.len(), + folder_count + ); + } + + entries +} + +/// Decodes an audio file and loads it into the engine's sample pool. +/// +/// Handles format detection, decoding, and sample rate conversion automatically. +/// The decoded audio is resampled to match the engine's sample rate if necessary. +/// +/// # Returns +/// +/// The sample pool index on success, or an error description on failure. +/// +/// # Errors +/// +/// Returns `Err` if: +/// - File cannot be opened or read +/// - Format is unsupported or corrupted +/// - No audio track is found +/// - Decoding fails completely (partial decode errors are skipped) +/// - Sample pool is full +pub fn load_sample_file(engine: &mut Engine, path: &Path) -> Result { + let file = File::open(path).map_err(|e| format!("Failed to open file: {e}"))?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| format!("Failed to probe format: {e}"))?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) + .ok_or("No audio track found")?; + + let codec_params = &track.codec_params; + let channels = codec_params.channels.map(|c| c.count()).unwrap_or(1) as u8; + let sample_rate = codec_params.sample_rate.unwrap_or(44100) as f32; + + let mut decoder = symphonia::default::get_codecs() + .make(codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {e}"))?; + + let track_id = track.id; + let mut samples: Vec = Vec::new(); + let mut sample_buf: Option> = None; + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(e) => return Err(format!("Failed to read packet: {e}")), + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(format!("Decode error: {e}")), + }; + + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + + let buf = sample_buf.get_or_insert_with(|| SampleBuffer::::new(duration, spec)); + buf.copy_interleaved_ref(decoded); + + samples.extend_from_slice(buf.samples()); + } + + if samples.is_empty() { + return Err("No samples decoded".to_string()); + } + + let target_sr = engine.sr; + let resampled = if (sample_rate - target_sr).abs() > 1.0 { + resample_linear(&samples, channels as usize, sample_rate, target_sr) + } else { + samples + }; + + engine + .load_sample(&resampled, channels, DEFAULT_BASE_FREQ) + .ok_or_else(|| "Sample pool full".to_string()) +} + +/// Resamples interleaved audio using linear interpolation. +/// +/// Simple but fast resampling suitable for non-critical applications. +/// For higher quality, consider using a dedicated resampling library like rubato. +fn resample_linear(samples: &[f32], channels: usize, from_sr: f32, to_sr: f32) -> Vec { + let ratio = to_sr / from_sr; + let in_frames = samples.len() / channels; + let out_frames = (in_frames as f32 * ratio) as usize; + let mut output = vec![0.0; out_frames * channels]; + + for out_frame in 0..out_frames { + let in_pos = out_frame as f32 / ratio; + let in_frame = in_pos as usize; + let next_frame = (in_frame + 1).min(in_frames - 1); + let frac = in_pos - in_frame as f32; + + for ch in 0..channels { + let s0 = samples[in_frame * channels + ch]; + let s1 = samples[next_frame * channels + ch]; + output[out_frame * channels + ch] = s0 + frac * (s1 - s0); + } + } + + output +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fb8f615 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,190 @@ +//! Doux audio synthesis engine CLI. +//! +//! Provides real-time audio synthesis with OSC control. Supports sample +//! playback, multiple output channels, and live audio input processing. + +use clap::Parser; +use cpal::traits::{DeviceTrait, StreamTrait}; +use doux::audio::{ + default_input_device, default_output_device, find_input_device, find_output_device, + list_input_devices, list_output_devices, max_output_channels, +}; +use doux::Engine; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// Command-line arguments for the doux audio engine. +#[derive(Parser)] +#[command(name = "doux")] +#[command(about = "Audio synthesis engine with OSC control", long_about = None)] +struct Args { + /// Directory containing audio samples to load. + #[arg(short, long)] + samples: Option, + + /// OSC port to listen on. + #[arg(short, long, default_value = "57120")] + port: u16, + + /// List available audio devices and exit. + #[arg(long)] + list_devices: bool, + + /// Input device (name or index). + #[arg(short, long)] + input: Option, + + /// Output device (name or index). + #[arg(short, long)] + output: Option, + + /// Number of output channels (default: 2, max depends on device). + #[arg(long, default_value = "2")] + channels: u16, + + /// Audio buffer size in samples (lower = less latency, higher = more stable). + /// Common values: 64, 128, 256, 512, 1024. Default: system choice. + #[arg(short, long)] + buffer_size: Option, +} + +fn print_devices() { + println!("Input devices:"); + for info in list_input_devices() { + let marker = if info.is_default { " *" } else { "" }; + println!(" {}: {}{}", info.index, info.name, marker); + } + + println!("\nOutput devices:"); + for info in list_output_devices() { + let marker = if info.is_default { " *" } else { "" }; + println!(" {}: {}{}", info.index, info.name, marker); + } +} + +fn main() { + let args = Args::parse(); + + if args.list_devices { + print_devices(); + return; + } + + // Resolve output device + let device = match &args.output { + Some(spec) => find_output_device(spec) + .unwrap_or_else(|| panic!("output device '{spec}' not found")), + None => default_output_device().expect("no output device"), + }; + + // Clamp channels to device maximum + let max_channels = max_output_channels(&device); + let output_channels = (args.channels as usize).min(max_channels as usize); + if args.channels as usize > output_channels { + eprintln!( + "Warning: device supports max {} channels, using that instead of {}", + max_channels, args.channels + ); + } + + let default_config = device.default_output_config().unwrap(); + let sample_rate = default_config.sample_rate().0 as f32; + + let config = cpal::StreamConfig { + channels: output_channels as u16, + sample_rate: default_config.sample_rate(), + buffer_size: args + .buffer_size + .map(cpal::BufferSize::Fixed) + .unwrap_or(cpal::BufferSize::Default), + }; + + println!("Output: {}", device.name().unwrap_or_default()); + println!("Sample rate: {sample_rate}"); + println!("Channels: {output_channels}"); + if let Some(buf) = args.buffer_size { + let latency_ms = buf as f32 / sample_rate * 1000.0; + println!("Buffer: {buf} samples ({latency_ms:.1} ms)"); + } + + // Initialize engine with sample index if provided + let mut engine = Engine::new_with_channels(sample_rate, output_channels); + + if let Some(ref dir) = args.samples { + println!("\nScanning samples from: {}", dir.display()); + let index = doux::loader::scan_samples_dir(dir); + println!("Found {} samples (lazy loading enabled)\n", index.len()); + engine.sample_index = index; + } + + let engine = Arc::new(Mutex::new(engine)); + + // Ring buffer for live audio input + let input_buffer: Arc>> = + Arc::new(Mutex::new(VecDeque::with_capacity(8192))); + + // Set up input stream if device available + let input_device = match &args.input { + Some(spec) => find_input_device(spec), + None => default_input_device(), + }; + let _input_stream = input_device.and_then(|input_device| { + let input_config = input_device.default_input_config().ok()?; + println!("Input: {}", input_device.name().unwrap_or_default()); + let buf = Arc::clone(&input_buffer); + let stream = input_device + .build_input_stream( + &input_config.into(), + move |data: &[f32], _| { + let mut b = buf.lock().unwrap(); + for &sample in data { + b.push_back(sample); + if b.len() > 8192 { + b.pop_front(); + } + } + }, + |err| eprintln!("input stream error: {err}"), + None, + ) + .ok()?; + stream.play().ok()?; + Some(stream) + }); + + // Build output stream with audio callback + let engine_clone = Arc::clone(&engine); + let input_buf_clone = Arc::clone(&input_buffer); + let live_scratch: Arc>> = Arc::new(Mutex::new(vec![0.0; 1024])); + let live_scratch_clone = Arc::clone(&live_scratch); + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _| { + let mut buf = input_buf_clone.lock().unwrap(); + let mut scratch = live_scratch_clone.lock().unwrap(); + if scratch.len() < data.len() { + scratch.resize(data.len(), 0.0); + } + for sample in scratch[..data.len()].iter_mut() { + *sample = buf.pop_front().unwrap_or(0.0); + } + drop(buf); + engine_clone + .lock() + .unwrap() + .process_block(data, &[], &scratch[..data.len()]); + }, + |err| eprintln!("stream error: {err}"), + None, + ) + .unwrap(); + + stream.play().unwrap(); + println!("Listening for OSC on port {}", args.port); + println!("Press Ctrl+C to stop"); + + // Block on OSC server (runs until interrupted) + doux::osc::run(engine, args.port); +} diff --git a/src/noise.rs b/src/noise.rs new file mode 100644 index 0000000..c50ca06 --- /dev/null +++ b/src/noise.rs @@ -0,0 +1,87 @@ +//! Colored noise generators. +//! +//! Transforms white noise into spectrally-shaped noise with different frequency +//! characteristics. Both generators are stateful filters that process white noise +//! sample-by-sample. +//! +//! # Noise Colors +//! +//! | Color | Slope | Character | +//! |-------|------------|----------------------------------| +//! | White | 0 dB/oct | Equal energy per frequency | +//! | Pink | -3 dB/oct | Equal energy per octave | +//! | Brown | -6 dB/oct | Rumbling, emphasizes low freqs | + +/// Pink noise generator using the Voss-McCartney algorithm. +/// +/// Applies a parallel bank of first-order lowpass filters to shape white noise +/// into pink noise with -3 dB/octave rolloff. The coefficients approximate an +/// ideal pink spectrum across the audio range. +/// +/// Also known as 1/f noise, pink noise has equal energy per octave, making it +/// useful for audio testing and as a natural-sounding noise source. +#[derive(Clone, Copy)] +pub struct PinkNoise { + b: [f32; 7], +} + +impl Default for PinkNoise { + fn default() -> Self { + Self { b: [0.0; 7] } + } +} + +impl PinkNoise { + /// Processes one white noise sample and returns the corresponding pink noise sample. + /// + /// The input should be uniformly distributed white noise in the range `[-1, 1]`. + /// Output is scaled to approximately the same amplitude range. + pub fn next(&mut self, white: f32) -> f32 { + self.b[0] = 0.99886 * self.b[0] + white * 0.0555179; + self.b[1] = 0.99332 * self.b[1] + white * 0.0750759; + self.b[2] = 0.96900 * self.b[2] + white * 0.153852; + self.b[3] = 0.86650 * self.b[3] + white * 0.3104856; + self.b[4] = 0.55000 * self.b[4] + white * 0.5329522; + self.b[5] = -0.7616 * self.b[5] - white * 0.0168980; + let pink = self.b[0] + + self.b[1] + + self.b[2] + + self.b[3] + + self.b[4] + + self.b[5] + + self.b[6] + + white * 0.5362; + self.b[6] = white * 0.115926; + pink * 0.11 + } +} + +/// Brown noise generator using leaky integration. +/// +/// Applies a simple first-order lowpass filter (leaky integrator) to produce +/// noise with -6 dB/octave rolloff. Named after Robert Brown (Brownian motion), +/// not the color. +/// +/// Also known as red noise or random walk noise. Has a deep, rumbling character +/// with strong low-frequency content. +#[derive(Clone, Copy)] +pub struct BrownNoise { + out: f32, +} + +impl Default for BrownNoise { + fn default() -> Self { + Self { out: 0.0 } + } +} + +impl BrownNoise { + /// Processes one white noise sample and returns the corresponding brown noise sample. + /// + /// The input should be uniformly distributed white noise in the range `[-1, 1]`. + /// Output amplitude depends on the integration coefficient. + pub fn next(&mut self, white: f32) -> f32 { + self.out = (self.out + 0.02 * white) / 1.02; + self.out + } +} diff --git a/src/orbit.rs b/src/orbit.rs new file mode 100644 index 0000000..24f2f33 --- /dev/null +++ b/src/orbit.rs @@ -0,0 +1,504 @@ +use crate::effects::Comb; +use crate::fastmath::ftz; +use crate::types::{DelayType, CHANNELS}; + +const MAX_DELAY_SAMPLES: usize = 48000; +const SILENCE_THRESHOLD: f32 = 1e-7; +const SILENCE_HOLDOFF: u32 = 48000; + +#[derive(Clone, Copy, Default)] +pub struct EffectParams { + pub delay_time: f32, + pub delay_feedback: f32, + pub delay_type: DelayType, + pub verb_decay: f32, + pub verb_damp: f32, + pub verb_predelay: f32, + pub verb_diff: f32, + pub comb_freq: f32, + pub comb_feedback: f32, + pub comb_damp: f32, +} + +#[derive(Clone)] +pub struct DelayLine { + buffer: Vec, + write_pos: usize, +} + +impl DelayLine { + pub fn new(max_samples: usize) -> Self { + Self { + buffer: vec![0.0; max_samples], + write_pos: 0, + } + } + + pub fn process(&mut self, input: f32, delay_samples: usize) -> f32 { + let delay_samples = delay_samples.min(self.buffer.len() - 1); + self.buffer[self.write_pos] = input; + + let read_pos = if self.write_pos >= delay_samples { + self.write_pos - delay_samples + } else { + self.buffer.len() - (delay_samples - self.write_pos) + }; + + self.write_pos = (self.write_pos + 1) % self.buffer.len(); + self.buffer[read_pos] + } + + pub fn read_at(&self, delay_samples: usize) -> f32 { + let delay_samples = delay_samples.min(self.buffer.len() - 1); + let read_pos = if self.write_pos >= delay_samples { + self.write_pos - delay_samples + } else { + self.buffer.len() - (delay_samples - self.write_pos) + }; + self.buffer[read_pos] + } + + pub fn write(&mut self, input: f32) { + self.buffer[self.write_pos] = input; + self.write_pos = (self.write_pos + 1) % self.buffer.len(); + } + + pub fn clear(&mut self) { + self.buffer.fill(0.0); + } +} + +impl Default for DelayLine { + fn default() -> Self { + Self::new(MAX_DELAY_SAMPLES) + } +} + +const REVERB_SR_REF: f32 = 29761.0; + +fn scale_delay(samples: usize, sr: f32) -> usize { + ((samples as f32 * sr / REVERB_SR_REF) as usize).max(1) +} + +#[derive(Clone)] +pub struct ReverbBuffer { + buffer: Vec, + write_pos: usize, +} + +impl ReverbBuffer { + pub fn new(size: usize) -> Self { + Self { + buffer: vec![0.0; size], + write_pos: 0, + } + } + + pub fn write(&mut self, value: f32) { + self.buffer[self.write_pos] = value; + self.write_pos = (self.write_pos + 1) % self.buffer.len(); + } + + pub fn read(&self, delay: usize) -> f32 { + let delay = delay.min(self.buffer.len() - 1); + let pos = if self.write_pos >= delay { + self.write_pos - delay + } else { + self.buffer.len() - (delay - self.write_pos) + }; + self.buffer[pos] + } + + pub fn read_write(&mut self, value: f32, delay: usize) -> f32 { + let out = self.read(delay); + self.write(value); + out + } + + pub fn allpass(&mut self, input: f32, delay: usize, coeff: f32) -> f32 { + let delayed = self.read(delay); + let v = input - coeff * delayed; + self.write(v); + delayed + coeff * v + } + + pub fn clear(&mut self) { + self.buffer.fill(0.0); + } +} + +#[derive(Clone)] +pub struct DattorroVerb { + pre_delay: ReverbBuffer, + in_diff1: ReverbBuffer, + in_diff2: ReverbBuffer, + in_diff3: ReverbBuffer, + in_diff4: ReverbBuffer, + decay_diff1_l: ReverbBuffer, + delay1_l: ReverbBuffer, + decay_diff2_l: ReverbBuffer, + delay2_l: ReverbBuffer, + decay_diff1_r: ReverbBuffer, + delay1_r: ReverbBuffer, + decay_diff2_r: ReverbBuffer, + delay2_r: ReverbBuffer, + damp_l: f32, + damp_r: f32, + pre_delay_len: usize, + in_diff1_len: usize, + in_diff2_len: usize, + in_diff3_len: usize, + in_diff4_len: usize, + decay_diff1_l_len: usize, + delay1_l_len: usize, + decay_diff2_l_len: usize, + delay2_l_len: usize, + decay_diff1_r_len: usize, + delay1_r_len: usize, + decay_diff2_r_len: usize, + delay2_r_len: usize, + tap1_l: usize, + tap2_l: usize, + tap3_l: usize, + tap4_l: usize, + tap5_l: usize, + tap6_l: usize, + tap7_l: usize, + tap1_r: usize, + tap2_r: usize, + tap3_r: usize, + tap4_r: usize, + tap5_r: usize, + tap6_r: usize, + tap7_r: usize, +} + +impl DattorroVerb { + pub fn new(sr: f32) -> Self { + let pre_delay_len = scale_delay(4800, sr); + let in_diff1_len = scale_delay(142, sr); + let in_diff2_len = scale_delay(107, sr); + let in_diff3_len = scale_delay(379, sr); + let in_diff4_len = scale_delay(277, sr); + let decay_diff1_l_len = scale_delay(672, sr); + let delay1_l_len = scale_delay(4453, sr); + let decay_diff2_l_len = scale_delay(1800, sr); + let delay2_l_len = scale_delay(3720, sr); + let decay_diff1_r_len = scale_delay(908, sr); + let delay1_r_len = scale_delay(4217, sr); + let decay_diff2_r_len = scale_delay(2656, sr); + let delay2_r_len = scale_delay(3163, sr); + + Self { + pre_delay: ReverbBuffer::new(pre_delay_len + 1), + in_diff1: ReverbBuffer::new(in_diff1_len + 1), + in_diff2: ReverbBuffer::new(in_diff2_len + 1), + in_diff3: ReverbBuffer::new(in_diff3_len + 1), + in_diff4: ReverbBuffer::new(in_diff4_len + 1), + decay_diff1_l: ReverbBuffer::new(decay_diff1_l_len + 1), + delay1_l: ReverbBuffer::new(delay1_l_len + 1), + decay_diff2_l: ReverbBuffer::new(decay_diff2_l_len + 1), + delay2_l: ReverbBuffer::new(delay2_l_len + 1), + decay_diff1_r: ReverbBuffer::new(decay_diff1_r_len + 1), + delay1_r: ReverbBuffer::new(delay1_r_len + 1), + decay_diff2_r: ReverbBuffer::new(decay_diff2_r_len + 1), + delay2_r: ReverbBuffer::new(delay2_r_len + 1), + damp_l: 0.0, + damp_r: 0.0, + pre_delay_len, + in_diff1_len, + in_diff2_len, + in_diff3_len, + in_diff4_len, + decay_diff1_l_len, + delay1_l_len, + decay_diff2_l_len, + delay2_l_len, + decay_diff1_r_len, + delay1_r_len, + decay_diff2_r_len, + delay2_r_len, + tap1_l: scale_delay(266, sr), + tap2_l: scale_delay(2974, sr), + tap3_l: scale_delay(1913, sr), + tap4_l: scale_delay(1996, sr), + tap5_l: scale_delay(1990, sr), + tap6_l: scale_delay(187, sr), + tap7_l: scale_delay(1066, sr), + tap1_r: scale_delay(353, sr), + tap2_r: scale_delay(3627, sr), + tap3_r: scale_delay(1228, sr), + tap4_r: scale_delay(2673, sr), + tap5_r: scale_delay(2111, sr), + tap6_r: scale_delay(335, sr), + tap7_r: scale_delay(121, sr), + } + } + + pub fn process( + &mut self, + input: f32, + decay: f32, + damping: f32, + predelay: f32, + diffusion: f32, + ) -> [f32; 2] { + let decay = decay.clamp(0.0, 0.99); + let damping = damping.clamp(0.0, 1.0); + let diffusion = diffusion.clamp(0.0, 1.0); + let diff1 = 0.75 * diffusion; + let diff2 = 0.625 * diffusion; + let decay_diff1 = 0.7 * diffusion; + let decay_diff2 = 0.5 * diffusion; + + let pre_delay_samples = + ((predelay * self.pre_delay_len as f32) as usize).min(self.pre_delay_len); + let input = ftz(input, 0.0001); + let pre = self.pre_delay.read_write(input, pre_delay_samples); + + let mut x = pre; + x = self.in_diff1.allpass(x, self.in_diff1_len, diff1); + x = self.in_diff2.allpass(x, self.in_diff2_len, diff1); + x = self.in_diff3.allpass(x, self.in_diff3_len, diff2); + x = self.in_diff4.allpass(x, self.in_diff4_len, diff2); + + let tank_l_in = x + self.delay2_r.read(self.delay2_r_len) * decay; + let tank_r_in = x + self.delay2_l.read(self.delay2_l_len) * decay; + + let mut l = self + .decay_diff1_l + .allpass(tank_l_in, self.decay_diff1_l_len, -decay_diff1); + l = self.delay1_l.read_write(l, self.delay1_l_len); + self.damp_l = ftz(l * (1.0 - damping) + self.damp_l * damping, 0.0001); + l = self.damp_l * decay; + l = self + .decay_diff2_l + .allpass(l, self.decay_diff2_l_len, decay_diff2); + self.delay2_l.write(l); + + let mut r = self + .decay_diff1_r + .allpass(tank_r_in, self.decay_diff1_r_len, -decay_diff1); + r = self.delay1_r.read_write(r, self.delay1_r_len); + self.damp_r = ftz(r * (1.0 - damping) + self.damp_r * damping, 0.0001); + r = self.damp_r * decay; + r = self + .decay_diff2_r + .allpass(r, self.decay_diff2_r_len, decay_diff2); + self.delay2_r.write(r); + + let out_l = self.delay1_l.read(self.tap1_l) + self.delay1_l.read(self.tap2_l) + - self.decay_diff2_l.read(self.tap3_l) + + self.delay2_l.read(self.tap4_l) + - self.delay1_r.read(self.tap5_r) + - self.decay_diff2_r.read(self.tap6_r) + - self.delay2_r.read(self.tap7_r); + + let out_r = self.delay1_r.read(self.tap1_r) + self.delay1_r.read(self.tap2_r) + - self.decay_diff2_r.read(self.tap3_r) + + self.delay2_r.read(self.tap4_r) + - self.delay1_l.read(self.tap5_l) + - self.decay_diff2_l.read(self.tap6_l) + - self.delay2_l.read(self.tap7_l); + + [out_l * 0.6, out_r * 0.6] + } + + pub fn clear(&mut self) { + self.pre_delay.clear(); + self.in_diff1.clear(); + self.in_diff2.clear(); + self.in_diff3.clear(); + self.in_diff4.clear(); + self.decay_diff1_l.clear(); + self.delay1_l.clear(); + self.decay_diff2_l.clear(); + self.delay2_l.clear(); + self.decay_diff1_r.clear(); + self.delay1_r.clear(); + self.decay_diff2_r.clear(); + self.delay2_r.clear(); + self.damp_l = 0.0; + self.damp_r = 0.0; + } +} + +pub struct Orbit { + pub delay: [DelayLine; CHANNELS], + pub delay_send: [f32; CHANNELS], + pub delay_out: [f32; CHANNELS], + pub delay_feedback: [f32; CHANNELS], + pub delay_lp: [f32; CHANNELS], + pub verb: DattorroVerb, + pub verb_send: [f32; CHANNELS], + pub verb_out: [f32; CHANNELS], + pub comb: Comb, + pub comb_send: [f32; CHANNELS], + pub comb_out: [f32; CHANNELS], + pub sr: f32, + silent_samples: u32, +} + +impl Orbit { + pub fn new(sr: f32) -> Self { + Self { + delay: [DelayLine::default(), DelayLine::default()], + delay_send: [0.0; CHANNELS], + delay_out: [0.0; CHANNELS], + delay_feedback: [0.0; CHANNELS], + delay_lp: [0.0; CHANNELS], + verb: DattorroVerb::new(sr), + verb_send: [0.0; CHANNELS], + verb_out: [0.0; CHANNELS], + comb: Comb::default(), + comb_send: [0.0; CHANNELS], + comb_out: [0.0; CHANNELS], + sr, + silent_samples: SILENCE_HOLDOFF + 1, + } + } + + pub fn clear_sends(&mut self) { + self.delay_send = [0.0; CHANNELS]; + self.verb_send = [0.0; CHANNELS]; + self.comb_send = [0.0; CHANNELS]; + } + + pub fn add_delay_send(&mut self, ch: usize, value: f32) { + self.delay_send[ch] += value; + } + + pub fn add_verb_send(&mut self, ch: usize, value: f32) { + self.verb_send[ch] += value; + } + + pub fn add_comb_send(&mut self, ch: usize, value: f32) { + self.comb_send[ch] += value; + } + + pub fn process(&mut self, p: &EffectParams) { + let has_input = self.delay_send[0] != 0.0 + || self.delay_send[1] != 0.0 + || self.verb_send[0] != 0.0 + || self.verb_send[1] != 0.0 + || self.comb_send[0] != 0.0 + || self.comb_send[1] != 0.0; + + if has_input { + self.silent_samples = 0; + } else if self.silent_samples > SILENCE_HOLDOFF { + self.delay_out = [0.0; CHANNELS]; + self.verb_out = [0.0; CHANNELS]; + self.comb_out = [0.0; CHANNELS]; + return; + } + + let delay_samples = ((p.delay_time * self.sr) as usize).min(MAX_DELAY_SAMPLES - 1); + let feedback = p.delay_feedback.clamp(0.0, 0.95); + + match p.delay_type { + DelayType::Standard => { + for c in 0..CHANNELS { + let fb = ftz(self.delay_feedback[c], 0.0001); + let input = self.delay_send[c] + fb * feedback; + self.delay_out[c] = self.delay[c].process(input, delay_samples); + self.delay_feedback[c] = self.delay_out[c]; + } + } + DelayType::PingPong => { + // True ping-pong: mono input → L only, then bounces L↔R + let mono_in = (self.delay_send[0] + self.delay_send[1]) * 0.5; + let fb_l = ftz(self.delay_feedback[0], 0.0001); + let fb_r = ftz(self.delay_feedback[1], 0.0001); + + // L gets new input + feedback from R + let input_l = mono_in + fb_r * feedback; + // R gets ONLY feedback from L (no direct input - this creates the bounce) + let input_r = fb_l * feedback; + + self.delay_out[0] = self.delay[0].process(input_l, delay_samples); + self.delay_out[1] = self.delay[1].process(input_r, delay_samples); + + self.delay_feedback[0] = self.delay_out[0]; + self.delay_feedback[1] = self.delay_out[1]; + } + DelayType::Tape => { + // Tape delay: one-pole lowpass in feedback (darkening each repeat) + const DAMP: f32 = 0.35; + for c in 0..CHANNELS { + // Apply lowpass to feedback before using it + let fb_raw = ftz(self.delay_feedback[c], 0.0001); + let fb = self.delay_lp[c] + DAMP * (fb_raw - self.delay_lp[c]); + self.delay_lp[c] = fb; + + let input = self.delay_send[c] + fb * feedback; + self.delay_out[c] = self.delay[c].process(input, delay_samples); + self.delay_feedback[c] = self.delay_out[c]; + } + } + DelayType::Multitap => { + // Multitap: 4 taps with straight-to-triplet morph + // feedback 0 = straight (1, 1/2, 1/4, 1/8) + // feedback 1 = triplet (1, 2/3, 1/3, 1/6) + // in between = swing + let swing = feedback; + let t = delay_samples as f32; + + let tap1 = delay_samples; + let tap2 = (t * (0.5 + swing * 0.167)).max(1.0) as usize; // 1/2 → 2/3 + let tap3 = (t * (0.25 + swing * 0.083)).max(1.0) as usize; // 1/4 → 1/3 + let tap4 = (t * (0.125 + swing * 0.042)).max(1.0) as usize; // 1/8 → 1/6 + + for c in 0..CHANNELS { + let fb = ftz(self.delay_feedback[c], 0.0001); + let input = self.delay_send[c] + fb * 0.5; + self.delay[c].write(input); + + let out1 = self.delay[c].read_at(tap1); + let out2 = self.delay[c].read_at(tap2) * 0.7; + let out3 = self.delay[c].read_at(tap3) * 0.5; + let out4 = self.delay[c].read_at(tap4) * 0.35; + + self.delay_out[c] = out1 + out2 + out3 + out4; + self.delay_feedback[c] = out1; + } + } + } + + let verb_input = (self.verb_send[0] + self.verb_send[1]) * 0.5; + let verb_stereo = self.verb.process( + verb_input, + p.verb_decay, + p.verb_damp, + p.verb_predelay, + p.verb_diff, + ); + self.verb_out[0] = verb_stereo[0]; + self.verb_out[1] = verb_stereo[1]; + + // Comb filter (mono in, mono out to both channels) + let comb_input = (self.comb_send[0] + self.comb_send[1]) * 0.5; + let comb_out = self.comb.process( + comb_input, + p.comb_freq, + p.comb_feedback, + p.comb_damp, + self.sr, + ); + self.comb_out[0] = comb_out; + self.comb_out[1] = comb_out; + + let energy = self.delay_out[0].abs() + + self.delay_out[1].abs() + + self.verb_out[0].abs() + + self.verb_out[1].abs() + + self.comb_out[0].abs() + + self.comb_out[1].abs(); + + if energy < SILENCE_THRESHOLD { + self.silent_samples = self.silent_samples.saturating_add(1); + } else { + self.silent_samples = 0; + } + } +} diff --git a/src/osc.rs b/src/osc.rs new file mode 100644 index 0000000..e515225 --- /dev/null +++ b/src/osc.rs @@ -0,0 +1,121 @@ +//! OSC (Open Sound Control) message receiver. +//! +//! Listens for UDP packets containing OSC messages and translates them into +//! engine commands. Runs in a dedicated thread, forwarding parsed messages +//! to the audio engine for evaluation. +//! +//! # Message Format +//! +//! OSC arguments are interpreted as key-value pairs and converted to a path +//! string for the engine. Arguments are processed in pairs: odd positions are +//! keys (must be strings), even positions are values. +//! +//! ```text +//! OSC: /play ["sound", "kick", "note", 60, "amp", 0.8] +//! → Engine path: "sound/kick/note/60/amp/0.8" +//! ``` +//! +//! # Protocol +//! +//! - Transport: UDP +//! - Default bind: `0.0.0.0:` (all interfaces) +//! - Supports both single messages and bundles (bundles are flattened) + +use rosc::{OscMessage, OscPacket, OscType}; +use std::net::UdpSocket; +use std::sync::{Arc, Mutex}; + +use crate::Engine; + +/// Maximum UDP packet size for incoming OSC messages. +const BUFFER_SIZE: usize = 4096; + +/// Starts the OSC receiver loop on the specified port. +/// +/// Binds to all interfaces (`0.0.0.0`) and blocks indefinitely, processing +/// incoming messages. Intended to be spawned in a dedicated thread. +/// +/// # Panics +/// +/// Panics if the UDP socket cannot be bound (e.g., port already in use). +pub fn run(engine: Arc>, port: u16) { + let addr = format!("0.0.0.0:{port}"); + let socket = UdpSocket::bind(&addr).expect("failed to bind OSC socket"); + + let mut buf = [0u8; BUFFER_SIZE]; + + loop { + match socket.recv_from(&mut buf) { + Ok((size, _addr)) => { + if let Ok(packet) = rosc::decoder::decode_udp(&buf[..size]) { + handle_packet(&engine, &packet.1); + } + } + Err(e) => { + eprintln!("OSC recv error: {e}"); + } + } + } +} + +/// Recursively processes an OSC packet, handling both messages and bundles. +fn handle_packet(engine: &Arc>, packet: &OscPacket) { + match packet { + OscPacket::Message(msg) => handle_message(engine, msg), + OscPacket::Bundle(bundle) => { + for p in &bundle.content { + handle_packet(engine, p); + } + } + } +} + +/// Converts an OSC message to a path string and evaluates it on the engine. +fn handle_message(engine: &Arc>, msg: &OscMessage) { + let path = osc_to_path(msg); + if !path.is_empty() { + if let Ok(mut e) = engine.lock() { + e.evaluate(&path); + } + } +} + +/// Converts OSC message arguments to a slash-separated path string. +/// +/// Arguments are processed as key-value pairs. Keys must be strings; +/// non-string keys cause the pair to be skipped. Values are converted +/// to their string representation. +fn osc_to_path(msg: &OscMessage) -> String { + let mut parts: Vec = Vec::new(); + let args = &msg.args; + let mut i = 0; + + while i + 1 < args.len() { + let key = match &args[i] { + OscType::String(s) => s.clone(), + _ => { + i += 1; + continue; + } + }; + let val = arg_to_string(&args[i + 1]); + parts.push(key); + parts.push(val); + i += 2; + } + + parts.join("/") +} + +/// Converts an OSC type to its string representation. +fn arg_to_string(arg: &OscType) -> String { + match arg { + OscType::Int(v) => v.to_string(), + OscType::Float(v) => v.to_string(), + OscType::Double(v) => v.to_string(), + OscType::Long(v) => v.to_string(), + OscType::String(s) => s.clone(), + OscType::Bool(b) => if *b { "1" } else { "0" }.to_string(), + _ => String::new(), + } +} diff --git a/src/oscillator.rs b/src/oscillator.rs new file mode 100644 index 0000000..20fa669 --- /dev/null +++ b/src/oscillator.rs @@ -0,0 +1,421 @@ +//! Band-limited oscillators with phase shaping. +//! +//! Provides a phasor-based oscillator system with multiple waveforms and +//! optional phase distortion. Anti-aliasing is achieved via PolyBLEP +//! (Polynomial Band-Limited Step) for discontinuous waveforms. +//! +//! # Waveforms +//! +//! | Name | Alias | Description | +//! |--------|-------|------------------------------------------| +//! | `sine` | - | Pure sinusoid | +//! | `tri` | - | Triangle wave | +//! | `saw` | - | Sawtooth with PolyBLEP anti-aliasing | +//! | `zaw` | - | Raw sawtooth (no anti-aliasing) | +//! | `pulse`| - | Variable-width pulse with PolyBLEP | +//! | `pulze`| - | Raw pulse (no anti-aliasing) | +//! +//! # Phase Shaping +//! +//! [`PhaseShape`] transforms the oscillator phase before waveform generation, +//! enabling complex timbres from simple waveforms: +//! +//! - **mult**: Phase multiplication creates harmonic partials +//! - **warp**: Power curve distortion shifts harmonic balance +//! - **mirror**: Reflection creates symmetrical waveforms +//! - **size**: Step quantization for lo-fi/bitcrushed effects + +use crate::fastmath::{exp2f, powf, sinf}; +use crate::types::LfoShape; +use std::f32::consts::PI; + +/// PolyBLEP correction for band-limited discontinuities. +/// +/// Applies a polynomial correction near waveform discontinuities to reduce +/// aliasing. The correction is applied within one sample of the transition. +/// +/// - `t`: Current phase position in `[0, 1)` +/// - `dt`: Phase increment per sample (frequency × inverse sample rate) +fn poly_blep(t: f32, dt: f32) -> f32 { + if t < dt { + let t = t / dt; + return t + t - t * t - 1.0; + } + if t > 1.0 - dt { + let t = (t - 1.0) / dt; + return t * t + t + t + 1.0; + } + 0.0 +} + +/// Phase transformation parameters for waveform shaping. +/// +/// Applies a chain of transformations to the oscillator phase: +/// mult → warp → mirror → size (in that order). +/// +/// All parameters have neutral defaults that result in no transformation. +#[derive(Clone, Copy)] +pub struct PhaseShape { + /// Phase multiplier. Values > 1 create harmonic overtones. + pub size: u16, + /// Phase multiplication factor. Default: 1.0 (no multiplication). + pub mult: f32, + /// Power curve exponent. Positive values compress early phase, + /// negative values compress late phase. Default: 0.0 (linear). + pub warp: f32, + /// Mirror/fold position in `[0, 1]`. Phase reflects at this point. + /// Default: 0.0 (disabled). + pub mirror: f32, +} + +impl Default for PhaseShape { + fn default() -> Self { + Self { + size: 0, + mult: 1.0, + warp: 0.0, + mirror: 0.0, + } + } +} + +impl PhaseShape { + /// Returns `true` if any shaping parameter is non-neutral. + #[inline] + pub fn is_active(&self) -> bool { + self.size >= 2 || self.mult != 1.0 || self.warp != 0.0 || self.mirror > 0.0 + } + + /// Applies the full transformation chain to a phase value. + /// + /// Input and output are in the range `[0, 1)`. + /// Assumes `is_active()` returned true; call unconditionally for simplicity + /// or guard with `is_active()` to skip the function call entirely. + #[inline] + pub fn apply(&self, phase: f32) -> f32 { + let mut p = phase; + + // MULT: multiply and wrap + if self.mult != 1.0 { + p = (p * self.mult).fract(); + if p < 0.0 { + p += 1.0; + } + } + + // WARP: power curve asymmetry + if self.warp != 0.0 { + p = powf(p, exp2f(self.warp * 2.0)); + } + + // MIRROR: reflect at position + if self.mirror > 0.0 && self.mirror < 1.0 { + let m = self.mirror; + p = if p < m { + p / m + } else { + 1.0 - (p - m) / (1.0 - m) + }; + } + + // SIZE: quantize + if self.size >= 2 { + let steps = self.size as f32; + p = ((p * steps).floor() / (steps - 1.0)).min(1.0); + } + + p + } +} + +/// Phase accumulator with waveform generation methods. +/// +/// Maintains a phase value in `[0, 1)` that advances each sample based on +/// frequency. Provides both stateful methods (advance phase) and stateless +/// methods (compute at arbitrary phase for unison/spread). +#[derive(Clone, Copy)] +pub struct Phasor { + /// Current phase position in `[0, 1)`. + pub phase: f32, + /// Held value for sample-and-hold LFO. + sh_value: f32, + /// PRNG state for sample-and-hold randomization. + sh_seed: u32, +} + +impl Default for Phasor { + fn default() -> Self { + Self { + phase: 0.0, + sh_value: 0.0, + sh_seed: 123456789, + } + } +} + +impl Phasor { + /// Advances the phase by one sample. + /// + /// - `freq`: Oscillator frequency in Hz + /// - `isr`: Inverse sample rate (1.0 / sample_rate) + pub fn update(&mut self, freq: f32, isr: f32) { + self.phase += freq * isr; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + if self.phase < 0.0 { + self.phase += 1.0; + } + } + + /// Generates an LFO sample for the given shape. + /// + /// Sample-and-hold (`Sh`) latches a new random value at each cycle start. + pub fn lfo(&mut self, shape: LfoShape, freq: f32, isr: f32) -> f32 { + let p = self.phase; + self.update(freq, isr); + + match shape { + LfoShape::Sine => sinf(p * 2.0 * PI), + LfoShape::Tri => { + if p < 0.5 { + 4.0 * p - 1.0 + } else { + 3.0 - 4.0 * p + } + } + LfoShape::Saw => p * 2.0 - 1.0, + LfoShape::Square => { + if p < 0.5 { + 1.0 + } else { + -1.0 + } + } + LfoShape::Sh => { + if self.phase < p { + self.sh_seed = self.sh_seed.wrapping_mul(1103515245).wrapping_add(12345); + self.sh_value = ((self.sh_seed >> 16) & 0x7fff) as f32 / 16383.5 - 1.0; + } + self.sh_value + } + } + } + + /// Pure sine wave. + pub fn sine(&mut self, freq: f32, isr: f32) -> f32 { + let s = sinf(self.phase * 2.0 * PI); + self.update(freq, isr); + s + } + + /// Triangle wave (no anti-aliasing needed, naturally band-limited). + pub fn tri(&mut self, freq: f32, isr: f32) -> f32 { + let s = if self.phase < 0.5 { + 4.0 * self.phase - 1.0 + } else { + 3.0 - 4.0 * self.phase + }; + self.update(freq, isr); + s + } + + /// Band-limited sawtooth using PolyBLEP. + pub fn saw(&mut self, freq: f32, isr: f32) -> f32 { + let dt = freq * isr; + let p = poly_blep(self.phase, dt); + let s = self.phase * 2.0 - 1.0 - p; + self.update(freq, isr); + s + } + + /// Raw sawtooth without anti-aliasing. + /// + /// Use for low frequencies or when aliasing is acceptable/desired. + pub fn zaw(&mut self, freq: f32, isr: f32) -> f32 { + let s = self.phase * 2.0 - 1.0; + self.update(freq, isr); + s + } + + /// Band-limited pulse wave with variable width using PolyBLEP. + /// + /// - `pw`: Pulse width in `[0, 1]`. 0.5 = square wave. + pub fn pulse(&mut self, freq: f32, pw: f32, isr: f32) -> f32 { + let dt = freq * isr; + let mut phi = self.phase + pw; + if phi >= 1.0 { + phi -= 1.0; + } + let p1 = poly_blep(phi, dt); + let p2 = poly_blep(self.phase, dt); + let pulse = 2.0 * (self.phase - phi) - p2 + p1; + self.update(freq, isr); + pulse + pw * 2.0 - 1.0 + } + + /// Raw pulse wave without anti-aliasing. + /// + /// - `duty`: Duty cycle in `[0, 1]`. 0.5 = square wave. + pub fn pulze(&mut self, freq: f32, duty: f32, isr: f32) -> f32 { + let s = if self.phase < duty { 1.0 } else { -1.0 }; + self.update(freq, isr); + s + } + + /// Sine wave with phase shaping. + pub fn sine_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(self.phase) + } else { + self.phase + }; + let s = sinf(p * 2.0 * PI); + self.update(freq, isr); + s + } + + /// Triangle wave with phase shaping. + pub fn tri_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(self.phase) + } else { + self.phase + }; + let s = if p < 0.5 { + 4.0 * p - 1.0 + } else { + 3.0 - 4.0 * p + }; + self.update(freq, isr); + s + } + + /// Sawtooth with phase shaping. Falls back to raw saw when shaped. + pub fn saw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 { + if !shape.is_active() { + return self.saw(freq, isr); + } + let p = shape.apply(self.phase); + let s = p * 2.0 - 1.0; + self.update(freq, isr); + s + } + + /// Raw sawtooth with phase shaping. + pub fn zaw_shaped(&mut self, freq: f32, isr: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(self.phase) + } else { + self.phase + }; + let s = p * 2.0 - 1.0; + self.update(freq, isr); + s + } + + /// Pulse wave with phase shaping. Falls back to raw pulse when shaped. + pub fn pulse_shaped(&mut self, freq: f32, pw: f32, isr: f32, shape: &PhaseShape) -> f32 { + if !shape.is_active() { + return self.pulse(freq, pw, isr); + } + let p = shape.apply(self.phase); + let s = if p < pw { 1.0 } else { -1.0 }; + self.update(freq, isr); + s + } + + /// Raw pulse with phase shaping. + pub fn pulze_shaped(&mut self, freq: f32, duty: f32, isr: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(self.phase) + } else { + self.phase + }; + let s = if p < duty { 1.0 } else { -1.0 }; + self.update(freq, isr); + s + } + + // ------------------------------------------------------------------------- + // Stateless variants for unison/spread - compute at arbitrary phase + // ------------------------------------------------------------------------- + + /// Sine at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn sine_at(&self, phase: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + sinf(p * 2.0 * PI) + } + + /// Triangle at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn tri_at(&self, phase: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + if p < 0.5 { + 4.0 * p - 1.0 + } else { + 3.0 - 4.0 * p + } + } + + /// Sawtooth at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn saw_at(&self, phase: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + p * 2.0 - 1.0 + } + + /// Raw sawtooth at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn zaw_at(&self, phase: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + p * 2.0 - 1.0 + } + + /// Pulse at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn pulse_at(&self, phase: f32, pw: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + if p < pw { + 1.0 + } else { + -1.0 + } + } + + /// Raw pulse at arbitrary phase (stateless, for unison voices). + #[inline] + pub fn pulze_at(&self, phase: f32, duty: f32, shape: &PhaseShape) -> f32 { + let p = if shape.is_active() { + shape.apply(phase) + } else { + phase + }; + if p < duty { + 1.0 + } else { + -1.0 + } + } +} diff --git a/src/plaits.rs b/src/plaits.rs new file mode 100644 index 0000000..78f24e4 --- /dev/null +++ b/src/plaits.rs @@ -0,0 +1,212 @@ +//! Unified wrapper for Mutable Instruments Plaits synthesis engines. +//! +//! This module provides a single enum that wraps all 13 synthesis engines from +//! the `mi_plaits_dsp` crate (a Rust port of the Mutable Instruments Plaits +//! Eurorack module). Each engine produces sound through a different synthesis +//! technique. +//! +//! # Engine Categories +//! +//! ## Pitched Engines +//! - [`Modal`](PlaitsEngine::Modal) - Physical modeling of resonant structures +//! - [`Va`](PlaitsEngine::Va) - Virtual analog (classic subtractive synthesis) +//! - [`Ws`](PlaitsEngine::Ws) - Waveshaping synthesis +//! - [`Fm`](PlaitsEngine::Fm) - 2-operator FM synthesis +//! - [`Grain`](PlaitsEngine::Grain) - Granular synthesis +//! - [`Additive`](PlaitsEngine::Additive) - Additive synthesis with harmonic control +//! - [`Wavetable`](PlaitsEngine::Wavetable) - Wavetable oscillator +//! - [`Chord`](PlaitsEngine::Chord) - Polyphonic chord generator +//! - [`Swarm`](PlaitsEngine::Swarm) - Swarm of detuned oscillators +//! - [`Noise`](PlaitsEngine::Noise) - Filtered noise with resonance +//! +//! ## Percussion Engines +//! - [`Bass`](PlaitsEngine::Bass) - Analog kick drum model +//! - [`Snare`](PlaitsEngine::Snare) - Analog snare drum model +//! - [`Hat`](PlaitsEngine::Hat) - Hi-hat synthesis +//! +//! # Control Parameters +//! +//! All engines share a common control interface via [`EngineParameters`]: +//! - `note` - MIDI note number (pitch) +//! - `harmonics` - Timbre brightness/harmonics control +//! - `timbre` - Primary timbre parameter +//! - `morph` - Secondary timbre/morph parameter +//! - `accent` - Velocity/accent amount +//! - `trigger` - Gate/trigger state + +use crate::types::{Source, BLOCK_SIZE}; +use mi_plaits_dsp::engine::additive_engine::AdditiveEngine; +use mi_plaits_dsp::engine::bass_drum_engine::BassDrumEngine; +use mi_plaits_dsp::engine::chord_engine::ChordEngine; +use mi_plaits_dsp::engine::fm_engine::FmEngine; +use mi_plaits_dsp::engine::grain_engine::GrainEngine; +use mi_plaits_dsp::engine::hihat_engine::HihatEngine; +use mi_plaits_dsp::engine::modal_engine::ModalEngine; +use mi_plaits_dsp::engine::noise_engine::NoiseEngine; +use mi_plaits_dsp::engine::snare_drum_engine::SnareDrumEngine; +use mi_plaits_dsp::engine::swarm_engine::SwarmEngine; +use mi_plaits_dsp::engine::virtual_analog_engine::VirtualAnalogEngine; +use mi_plaits_dsp::engine::waveshaping_engine::WaveshapingEngine; +use mi_plaits_dsp::engine::wavetable_engine::WavetableEngine; +use mi_plaits_dsp::engine::{Engine, EngineParameters}; + +/// Wrapper enum containing all Plaits synthesis engines. +/// +/// Only one engine is active at a time. The engine is lazily initialized +/// when first needed and can be switched by creating a new instance with +/// [`PlaitsEngine::new`]. +pub enum PlaitsEngine { + /// Physical modeling of resonant structures (strings, plates, tubes). + Modal(ModalEngine), + /// Classic virtual analog with saw, pulse, and sub oscillator. + Va(VirtualAnalogEngine), + /// Waveshaping synthesis for harsh, aggressive timbres. + Ws(WaveshapingEngine), + /// Two-operator FM synthesis. + Fm(FmEngine), + /// Granular synthesis with pitch-shifting grains. + Grain(GrainEngine), + /// Additive synthesis with individual harmonic control. + Additive(AdditiveEngine), + /// Wavetable oscillator with smooth morphing. + Wavetable(WavetableEngine<'static>), + /// Polyphonic chord generator (boxed due to size). + Chord(Box>), + /// Swarm of detuned sawtooth oscillators. + Swarm(SwarmEngine), + /// Filtered noise with variable resonance. + Noise(NoiseEngine), + /// Analog bass drum synthesis. + Bass(BassDrumEngine), + /// Analog snare drum synthesis. + Snare(SnareDrumEngine), + /// Metallic hi-hat synthesis. + Hat(HihatEngine), +} + +impl PlaitsEngine { + /// Creates and initializes a new engine based on the given source type. + /// + /// # Panics + /// Panics if `source` is not a Plaits source variant (e.g., `Source::Tri`). + pub fn new(source: Source, sample_rate: f32) -> Self { + match source { + Source::PlModal => { + let mut e = ModalEngine::new(BLOCK_SIZE); + e.init(sample_rate); + Self::Modal(e) + } + Source::PlVa => { + let mut e = VirtualAnalogEngine::new(BLOCK_SIZE); + e.init(sample_rate); + Self::Va(e) + } + Source::PlWs => { + let mut e = WaveshapingEngine::new(); + e.init(sample_rate); + Self::Ws(e) + } + Source::PlFm => { + let mut e = FmEngine::new(); + e.init(sample_rate); + Self::Fm(e) + } + Source::PlGrain => { + let mut e = GrainEngine::new(); + e.init(sample_rate); + Self::Grain(e) + } + Source::PlAdd => { + let mut e = AdditiveEngine::new(); + e.init(sample_rate); + Self::Additive(e) + } + Source::PlWt => { + let mut e = WavetableEngine::new(); + e.init(sample_rate); + Self::Wavetable(e) + } + Source::PlChord => { + let mut e = ChordEngine::new(); + e.init(sample_rate); + Self::Chord(Box::new(e)) + } + Source::PlSwarm => { + let mut e = SwarmEngine::new(); + e.init(sample_rate); + Self::Swarm(e) + } + Source::PlNoise => { + let mut e = NoiseEngine::new(BLOCK_SIZE); + e.init(sample_rate); + Self::Noise(e) + } + Source::PlBass => { + let mut e = BassDrumEngine::new(); + e.init(sample_rate); + Self::Bass(e) + } + Source::PlSnare => { + let mut e = SnareDrumEngine::new(); + e.init(sample_rate); + Self::Snare(e) + } + Source::PlHat => { + let mut e = HihatEngine::new(BLOCK_SIZE); + e.init(sample_rate); + Self::Hat(e) + } + _ => unreachable!(), + } + } + + /// Renders a block of audio samples. + /// + /// # Arguments + /// - `params` - Engine parameters (pitch, timbre, morph, etc.) + /// - `out` - Output buffer for main signal (length must be `BLOCK_SIZE`) + /// - `aux` - Output buffer for auxiliary signal (length must be `BLOCK_SIZE`) + /// - `already_enveloped` - Set to true by percussion engines that apply their own envelope + pub fn render( + &mut self, + params: &EngineParameters, + out: &mut [f32], + aux: &mut [f32], + already_enveloped: &mut bool, + ) { + match self { + Self::Modal(e) => e.render(params, out, aux, already_enveloped), + Self::Va(e) => e.render(params, out, aux, already_enveloped), + Self::Ws(e) => e.render(params, out, aux, already_enveloped), + Self::Fm(e) => e.render(params, out, aux, already_enveloped), + Self::Grain(e) => e.render(params, out, aux, already_enveloped), + Self::Additive(e) => e.render(params, out, aux, already_enveloped), + Self::Wavetable(e) => e.render(params, out, aux, already_enveloped), + Self::Chord(e) => e.render(params, out, aux, already_enveloped), + Self::Swarm(e) => e.render(params, out, aux, already_enveloped), + Self::Noise(e) => e.render(params, out, aux, already_enveloped), + Self::Bass(e) => e.render(params, out, aux, already_enveloped), + Self::Snare(e) => e.render(params, out, aux, already_enveloped), + Self::Hat(e) => e.render(params, out, aux, already_enveloped), + } + } + + /// Returns the [`Source`] variant corresponding to the current engine. + pub fn source(&self) -> Source { + match self { + Self::Modal(_) => Source::PlModal, + Self::Va(_) => Source::PlVa, + Self::Ws(_) => Source::PlWs, + Self::Fm(_) => Source::PlFm, + Self::Grain(_) => Source::PlGrain, + Self::Additive(_) => Source::PlAdd, + Self::Wavetable(_) => Source::PlWt, + Self::Chord(_) => Source::PlChord, + Self::Swarm(_) => Source::PlSwarm, + Self::Noise(_) => Source::PlNoise, + Self::Bass(_) => Source::PlBass, + Self::Snare(_) => Source::PlSnare, + Self::Hat(_) => Source::PlHat, + } + } +} diff --git a/src/repl.rs b/src/repl.rs new file mode 100644 index 0000000..a9cc854 --- /dev/null +++ b/src/repl.rs @@ -0,0 +1,429 @@ +//! Interactive REPL for the doux audio engine. +//! +//! Provides a command-line interface for live-coding audio patterns with +//! readline-style editing and persistent history. +//! +//! # Usage +//! +//! ```text +//! doux-repl [OPTIONS] +//! +//! Options: +//! -s, --samples Directory containing audio samples +//! -i, --input Input device (name or index) +//! -o, --output Output device (name or index) +//! --channels Number of output channels (default: 2) +//! --list-devices List available audio devices and exit +//! ``` +//! +//! # REPL Commands +//! +//! | Command | Alias | Description | +//! |-----------|-------|--------------------------------------| +//! | `.quit` | `.q` | Exit the REPL | +//! | `.reset` | `.r` | Reset engine state | +//! | `.hush` | | Fade out all voices | +//! | `.panic` | | Immediately silence all voices | +//! | `.voices` | | Show active voice count | +//! | `.time` | | Show engine time in seconds | +//! | `.help` | `.h` | Show available commands | +//! +//! Any other input is evaluated as a doux pattern. + +use clap::Parser; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Device, Host}; +use doux::Engine; +use rustyline::completion::Completer; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::Helper; +use std::borrow::Cow; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +// ANSI color codes +const RESET: &str = "\x1b[0m"; +const GRAY: &str = "\x1b[90m"; +const BOLD: &str = "\x1b[1m"; +const RED: &str = "\x1b[31m"; +const DIM_GRAY: &str = "\x1b[2;90m"; +const CYAN: &str = "\x1b[36m"; + +struct DouxHighlighter; + +impl Highlighter for DouxHighlighter { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + // Comment: everything after // + if let Some(idx) = line.find("//") { + let before = &line[..idx]; + let comment = &line[idx..]; + let highlighted_before = highlight_pattern(before); + return Cow::Owned(format!("{highlighted_before}{DIM_GRAY}{comment}{RESET}")); + } + + // Dot command + if line.trim_start().starts_with('.') { + return Cow::Owned(format!("{CYAN}{line}{RESET}")); + } + + // Pattern with /key/value + Cow::Owned(highlight_pattern(line)) + } + + fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool { + true + } +} + +fn highlight_pattern(line: &str) -> String { + let mut result = String::new(); + let mut chars = line.chars().peekable(); + let mut after_slash = false; + + while let Some(c) = chars.next() { + if c == '/' { + result.push_str(GRAY); + result.push(c); + result.push_str(RESET); + after_slash = true; + } else if after_slash { + // Collect the token until next / + let mut token = String::new(); + token.push(c); + while let Some(&next) = chars.peek() { + if next == '/' { + break; + } + token.push(chars.next().unwrap()); + } + // Is it a number? + if is_number(&token) { + result.push_str(RED); + result.push_str(&token); + result.push_str(RESET); + } else { + result.push_str(BOLD); + result.push_str(&token); + result.push_str(RESET); + } + after_slash = false; + } else { + result.push(c); + } + } + result +} + +fn is_number(s: &str) -> bool { + if s.is_empty() { + return false; + } + let s = s.strip_prefix('-').unwrap_or(s); + s.chars().all(|c| c.is_ascii_digit() || c == '.') +} + +impl Completer for DouxHighlighter { + type Candidate = String; +} + +impl Hinter for DouxHighlighter { + type Hint = String; +} + +impl Validator for DouxHighlighter {} + +impl Helper for DouxHighlighter {} + +/// Maximum samples buffered from audio input. +const INPUT_BUFFER_SIZE: usize = 8192; + +#[derive(Parser)] +#[command(name = "doux-repl")] +#[command(about = "Interactive REPL for doux audio engine")] +struct Args { + /// Directory containing audio samples + #[arg(short, long)] + samples: Option, + + /// List available audio devices and exit + #[arg(long)] + list_devices: bool, + + /// Input device (name or index) + #[arg(short, long)] + input: Option, + + /// Output device (name or index) + #[arg(short, long)] + output: Option, + + /// Number of output channels (default: 2, max depends on device) + #[arg(long, default_value = "2")] + channels: u16, + + /// Audio buffer size in samples (lower = less latency, higher = more stable). + /// Common values: 64, 128, 256, 512, 1024. Default: system choice. + #[arg(short, long)] + buffer_size: Option, +} + +/// Prints available audio input and output devices. +/// +/// Default devices are marked with `*`. +fn list_devices(host: &Host) { + let default_in = host.default_input_device().and_then(|d| d.name().ok()); + let default_out = host.default_output_device().and_then(|d| d.name().ok()); + + println!("Input devices:"); + if let Ok(devices) = host.input_devices() { + for (i, d) in devices.enumerate() { + let name = d.name().unwrap_or_else(|_| "???".into()); + let marker = if Some(&name) == default_in.as_ref() { + " *" + } else { + "" + }; + println!(" {i}: {name}{marker}"); + } + } + + println!("\nOutput devices:"); + if let Ok(devices) = host.output_devices() { + for (i, d) in devices.enumerate() { + let name = d.name().unwrap_or_else(|_| "???".into()); + let marker = if Some(&name) == default_out.as_ref() { + " *" + } else { + "" + }; + println!(" {i}: {name}{marker}"); + } + } +} + +/// Finds a device by index or substring match on name. +fn find_device(devices: I, spec: &str) -> Option +where + I: Iterator, +{ + let devices: Vec<_> = devices.collect(); + if let Ok(idx) = spec.parse::() { + return devices.into_iter().nth(idx); + } + let spec_lower = spec.to_lowercase(); + devices.into_iter().find(|d| { + d.name() + .map(|n| n.to_lowercase().contains(&spec_lower)) + .unwrap_or(false) + }) +} + +/// Prints available REPL commands. +fn print_help() { + println!("Commands:"); + println!(" .quit, .q Exit the REPL"); + println!(" .reset, .r Reset engine state"); + println!(" .hush Fade out all voices"); + println!(" .panic Immediately silence all voices"); + println!(" .voices Show active voice count"); + println!(" .time Show engine time"); + println!(" .stats, .s Show engine telemetry (CPU, voices, memory)"); + println!(" .help, .h Show this help"); + println!(); + println!("Any other input is evaluated as a doux pattern."); +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + let host = cpal::default_host(); + + if args.list_devices { + list_devices(&host); + return Ok(()); + } + + let device = match &args.output { + Some(spec) => host + .output_devices() + .ok() + .and_then(|d| find_device(d, spec)) + .unwrap_or_else(|| panic!("output device '{spec}' not found")), + None => host.default_output_device().expect("no output device"), + }; + + let max_channels = device + .supported_output_configs() + .map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2)) + .unwrap_or(2); + + let output_channels = (args.channels as usize).min(max_channels as usize); + if args.channels as usize > output_channels { + eprintln!( + "Warning: device supports max {} channels, using that instead of {}", + max_channels, args.channels + ); + } + + let default_config = device.default_output_config()?; + let sample_rate = default_config.sample_rate().0 as f32; + + let config = cpal::StreamConfig { + channels: output_channels as u16, + sample_rate: default_config.sample_rate(), + buffer_size: args + .buffer_size + .map(cpal::BufferSize::Fixed) + .unwrap_or(cpal::BufferSize::Default), + }; + + println!("doux-repl"); + print!( + "Output: {} @ {}Hz, {} channels", + device.name().unwrap_or_default(), + sample_rate, + output_channels + ); + if let Some(buf) = args.buffer_size { + let latency_ms = buf as f32 / sample_rate * 1000.0; + println!(", {buf} samples ({latency_ms:.1} ms)"); + } else { + println!(); + } + + let mut engine = Engine::new_with_channels(sample_rate, output_channels); + + if let Some(ref dir) = args.samples { + let index = doux::loader::scan_samples_dir(dir); + println!("Samples: {} from {}", index.len(), dir.display()); + engine.sample_index = index; + } + + let engine = Arc::new(Mutex::new(engine)); + let input_buffer: Arc>> = + Arc::new(Mutex::new(VecDeque::with_capacity(INPUT_BUFFER_SIZE))); + + let input_device = match &args.input { + Some(spec) => host.input_devices().ok().and_then(|d| find_device(d, spec)), + None => host.default_input_device(), + }; + + let _input_stream = input_device.and_then(|input_device| { + let input_config = input_device.default_input_config().ok()?; + println!("Input: {}", input_device.name().unwrap_or_default()); + let buf = Arc::clone(&input_buffer); + let stream = input_device + .build_input_stream( + &input_config.into(), + move |data: &[f32], _| { + let mut b = buf.lock().unwrap(); + b.extend(data.iter().copied()); + let excess = b.len().saturating_sub(INPUT_BUFFER_SIZE); + if excess > 0 { + drop(b.drain(..excess)); + } + }, + |err| eprintln!("input error: {err}"), + None, + ) + .ok()?; + stream.play().ok()?; + Some(stream) + }); + + let engine_clone = Arc::clone(&engine); + let input_buf_clone = Arc::clone(&input_buffer); + let sr = sample_rate; + let ch = output_channels; + + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _| { + let mut scratch = vec![0.0f32; data.len()]; + { + let mut buf = input_buf_clone.lock().unwrap(); + let available = buf.len().min(data.len()); + for (i, sample) in buf.drain(..available).enumerate() { + scratch[i] = sample; + } + } + let mut engine = engine_clone.lock().unwrap(); + let buffer_samples = data.len() / ch; + let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64; + engine.metrics.load.set_buffer_time(buffer_time_ns); + engine.process_block(data, &[], &scratch); + }, + |err| eprintln!("stream error: {err}"), + None, + )?; + stream.play()?; + + let mut rl = rustyline::Editor::new()?; + rl.set_helper(Some(DouxHighlighter)); + let history_path = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".doux_history")) + .unwrap_or_else(|_| PathBuf::from(".doux_history")); + let _ = rl.load_history(&history_path); + + println!("Type .help for commands"); + + loop { + match rl.readline("doux> ") { + Ok(line) => { + let _ = rl.add_history_entry(&line); + let trimmed = line.trim(); + + match trimmed { + ".quit" | ".q" => break, + ".reset" | ".r" => { + engine.lock().unwrap().evaluate("/doux/reset"); + } + ".voices" | ".v" => { + println!("{}", engine.lock().unwrap().active_voices); + } + ".time" | ".t" => { + println!("{:.3}s", engine.lock().unwrap().time); + } + ".stats" | ".s" => { + use std::sync::atomic::Ordering; + let e = engine.lock().unwrap(); + let cpu = e.metrics.load.get_load() * 100.0; + let voices = e.metrics.active_voices.load(Ordering::Relaxed); + let peak = e.metrics.peak_voices.load(Ordering::Relaxed); + let sched = e.metrics.schedule_depth.load(Ordering::Relaxed); + let mem = e.metrics.sample_pool_mb(); + println!("CPU: {cpu:5.1}%"); + println!("Voices: {voices:3}/{}", doux::types::MAX_VOICES); + println!("Peak: {peak:3}"); + println!("Schedule: {sched:3}"); + println!("Samples: {mem:.1} MB"); + } + ".hush" => { + engine.lock().unwrap().hush(); + } + ".panic" => { + engine.lock().unwrap().panic(); + } + ".help" | ".h" => { + print_help(); + } + s if !s.is_empty() => { + engine.lock().unwrap().evaluate(s); + } + _ => {} + } + } + Err(ReadlineError::Interrupted | ReadlineError::Eof) => break, + Err(e) => { + eprintln!("readline error: {e}"); + break; + } + } + } + + let _ = rl.save_history(&history_path); + Ok(()) +} diff --git a/src/sample.rs b/src/sample.rs new file mode 100644 index 0000000..b4592fa --- /dev/null +++ b/src/sample.rs @@ -0,0 +1,264 @@ +//! Sample storage and playback primitives. +//! +//! Provides a memory pool for audio samples and playback cursors for reading +//! them back at variable speeds with interpolation. +//! +//! # Architecture +//! +//! ```text +//! SampleEntry (index) SamplePool (storage) FileSource (playhead) +//! ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +//! │ path: kick.wav │ │ [f32; N] │ │ sample_idx: 0 │ +//! │ name: "kick" │────▶│ ├─ sample 0 ─────│◀────│ pos: 0.0 │ +//! │ loaded: Some(0) │ │ ├─ sample 1 │ │ begin: 0.0 │ +//! └─────────────────┘ │ └─ ... │ │ end: 1.0 │ +//! └──────────────────┘ └─────────────────┘ +//! ``` +//! +//! - [`SampleEntry`]: Metadata for lazy-loaded samples (path, name, pool index) +//! - [`SamplePool`]: Contiguous f32 storage for all loaded sample data +//! - [`SampleInfo`]: Location and format of a sample within the pool +//! - [`FileSource`]: Playback cursor with position, speed, and loop points +//! - [`WebSampleSource`]: Simplified playback for WASM (no interpolation) + +use std::path::PathBuf; + +/// Index entry for a discoverable sample file. +/// +/// Created during directory scanning with [`crate::loader::scan_samples_dir`]. +/// The `loaded` field is `None` until the sample is actually decoded and +/// added to the pool. +pub struct SampleEntry { + /// Filesystem path to the audio file. + pub path: PathBuf, + /// Display name (derived from filename or folder/index). + pub name: String, + /// Pool index if loaded, `None` if not yet decoded. + pub loaded: Option, +} + +/// Contiguous storage for all loaded sample data. +/// +/// Samples are stored sequentially as interleaved f32 frames. Each sample's +/// location is tracked by a corresponding [`SampleInfo`]. +/// +/// This design minimizes allocations and improves cache locality compared +/// to storing each sample in a separate `Vec`. +#[derive(Default)] +pub struct SamplePool { + /// Raw interleaved sample data for all loaded samples. + pub data: Vec, +} + +impl SamplePool { + /// Creates an empty sample pool. + pub fn new() -> Self { + Self::default() + } + + /// Adds sample data to the pool and returns its metadata. + /// + /// The samples should be interleaved if multi-channel (e.g., `[L, R, L, R, ...]`). + /// + /// # Parameters + /// + /// - `samples`: Interleaved audio data + /// - `channels`: Number of channels (1 = mono, 2 = stereo) + /// - `freq`: Base frequency in Hz for pitch calculations + /// + /// # Returns + /// + /// [`SampleInfo`] describing the sample's location in the pool. + pub fn add(&mut self, samples: &[f32], channels: u8, freq: f32) -> Option { + let frames = samples.len() / channels as usize; + let offset = self.data.len(); + + let info = SampleInfo { + offset, + frames: frames as u32, + channels, + freq, + }; + + self.data.extend_from_slice(samples); + Some(info) + } + + /// Returns the total memory usage in megabytes. + pub fn size_mb(&self) -> f32 { + (self.data.len() * 4) as f32 / (1024.0 * 1024.0) + } +} + +/// Metadata for a sample stored in the pool. +/// +/// Describes where a sample lives in the pool's data array and its format. +#[derive(Clone, Copy, Default)] +pub struct SampleInfo { + /// Byte offset into [`SamplePool::data`] where this sample begins. + pub offset: usize, + /// Total number of frames (samples per channel). + pub frames: u32, + /// Number of interleaved channels. + pub channels: u8, + /// Base frequency in Hz (used for pitch-shifting calculations). + pub freq: f32, +} + +/// Playback cursor for reading samples from the pool. +/// +/// Tracks playback position and supports: +/// - Variable-speed playback (including reverse with negative speed) +/// - Start/end points for partial playback or slicing +/// - Linear interpolation between samples for smooth pitch shifting +#[derive(Clone, Copy)] +pub struct FileSource { + /// Index into the sample info array. + pub sample_idx: usize, + /// Current playback position in frames (fractional for interpolation). + pub pos: f32, + /// Start point as fraction of total length `[0.0, 1.0]`. + pub begin: f32, + /// End point as fraction of total length `[0.0, 1.0]`. + pub end: f32, +} + +impl Default for FileSource { + fn default() -> Self { + Self { + sample_idx: 0, + pos: 0.0, + begin: 0.0, + end: 1.0, + } + } +} + +impl FileSource { + /// Creates a new playback cursor for a sample with start/end points. + /// + /// Points are clamped to valid ranges: begin to `[0, 1]`, end to `[begin, 1]`. + pub fn new(sample_idx: usize, begin: f32, end: f32) -> Self { + let begin_clamped = begin.clamp(0.0, 1.0); + Self { + sample_idx, + pos: 0.0, + begin: begin_clamped, + end: end.clamp(begin_clamped, 1.0), + } + } + + /// Reads the next sample value and advances the playback position. + /// + /// Uses linear interpolation for fractional positions, enabling smooth + /// pitch shifting without stair-stepping artifacts. + /// + /// # Parameters + /// + /// - `pool`: The sample pool's data slice + /// - `info`: Sample metadata (offset, frames, channels) + /// - `speed`: Playback rate multiplier (1.0 = normal, 2.0 = double speed) + /// - `channel`: Which channel to read (clamped to available channels) + /// + /// # Returns + /// + /// The interpolated sample value, or `0.0` if past the end point. + pub fn update(&mut self, pool: &[f32], info: &SampleInfo, speed: f32, channel: usize) -> f32 { + let begin_frame = (self.begin * info.frames as f32) as usize; + let end_frame = (self.end * info.frames as f32) as usize; + let channels = info.channels as usize; + + let current = self.pos as usize + begin_frame; + if current >= end_frame { + return 0.0; + } + + let frac = self.pos.fract(); + let ch = channel.min(channels - 1); + + let idx0 = info.offset + current * channels + ch; + let idx1 = if current + 1 < end_frame { + info.offset + (current + 1) * channels + ch + } else { + idx0 + }; + + let s0 = pool.get(idx0).copied().unwrap_or(0.0); + let s1 = pool.get(idx1).copied().unwrap_or(0.0); + + let sample = s0 + frac * (s1 - s0); + + self.pos += speed; + sample + } + + /// Returns `true` if playback has reached or passed the end point. + pub fn is_done(&self, info: &SampleInfo) -> bool { + let begin_frame = (self.begin * info.frames as f32) as usize; + let end_frame = (self.end * info.frames as f32) as usize; + let current = self.pos as usize + begin_frame; + current >= end_frame + } +} + +/// Simplified sample playback for WASM environments. +/// +/// Unlike [`FileSource`], this struct embeds its [`SampleInfo`] and does not +/// perform interpolation. Designed for web playback where JavaScript populates +/// a shared PCM buffer that Rust reads from. +#[derive(Clone, Copy, Default)] +pub struct WebSampleSource { + /// Sample metadata (location, size, format). + pub info: SampleInfo, + /// Current playback position in frames (relative to begin point). + pub pos: f32, + /// Normalized start point (0.0 = sample start, 1.0 = sample end). + pub begin: f32, + /// Normalized end point (0.0 = sample start, 1.0 = sample end). + pub end: f32, +} + +impl WebSampleSource { + /// Creates a new sample source with the given loop points. + /// + /// Both `begin` and `end` are normalized values in the range 0.0 to 1.0, + /// representing positions within the sample. Values are clamped automatically. + pub fn new(info: SampleInfo, begin: f32, end: f32) -> Self { + let begin_clamped = begin.clamp(0.0, 1.0); + Self { + info, + pos: 0.0, + begin: begin_clamped, + end: end.clamp(begin_clamped, 1.0), + } + } + + /// Advances playback and returns the next sample value. + /// + /// Returns 0.0 if playback has reached the end point. The `speed` parameter + /// controls playback rate (1.0 = normal, 2.0 = double speed, 0.5 = half speed). + /// The `channel` parameter selects which channel to read (clamped to available channels). + pub fn update(&mut self, pcm_buffer: &[f32], speed: f32, channel: usize) -> f32 { + let begin_frame = (self.begin * self.info.frames as f32) as usize; + let end_frame = (self.end * self.info.frames as f32) as usize; + let current = self.pos as usize + begin_frame; + + if current >= end_frame { + return 0.0; + } + + let ch = channel.min(self.info.channels as usize - 1); + let idx = self.info.offset + current * self.info.channels as usize + ch; + let sample = pcm_buffer.get(idx).copied().unwrap_or(0.0); + self.pos += speed; + sample + } + + /// Returns true if playback has reached or passed the end point. + pub fn is_done(&self) -> bool { + let begin_frame = (self.begin * self.info.frames as f32) as usize; + let end_frame = (self.end * self.info.frames as f32) as usize; + let current = self.pos as usize + begin_frame; + current >= end_frame + } +} diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..058a3bc --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,98 @@ +//! Time-based event scheduling with sorted storage. +//! +//! Manages a queue of [`Event`]s that should fire at specific times. +//! Events are kept sorted by time for O(1) early-exit when no events are ready. +//! +//! # Event Lifecycle +//! +//! 1. Event with `time` field is parsed → inserted in sorted order +//! 2. Engine calls `process_schedule()` each sample +//! 3. When `event.time <= engine.time`: +//! - Event fires (triggers voice/sound) +//! - If `repeat` is set, event is re-inserted with new time +//! - Otherwise, event is removed +//! +//! # Complexity +//! +//! - Insertion: O(N) due to sorted insert (infrequent, ~10-100/sec) +//! - Processing: O(1) when no events ready (99.9% of calls) +//! - Processing: O(K) when K events fire (rare) +//! +//! # Capacity +//! +//! Limited to [`MAX_EVENTS`](crate::types::MAX_EVENTS) to prevent unbounded +//! growth. Events beyond this limit are silently dropped. + +use crate::event::Event; +use crate::types::MAX_EVENTS; + +/// Queue of time-scheduled events, sorted by time ascending. +/// +/// Invariant: `events[i].time <= events[i+1].time` for all valid indices. +/// This enables O(1) early-exit: if `events[0].time > now`, no events are ready. +pub struct Schedule { + events: Vec, +} + +impl Schedule { + /// Creates an empty schedule with pre-allocated capacity. + pub fn new() -> Self { + Self { + events: Vec::with_capacity(MAX_EVENTS), + } + } + + /// Adds an event to the schedule in sorted order. + /// + /// Events at capacity are silently dropped. + /// Insertion is O(N) but occurs infrequently (user actions). + pub fn push(&mut self, event: Event) { + if self.events.len() >= MAX_EVENTS { + return; + } + let time = event.time.unwrap_or(f64::MAX); + let pos = self + .events + .partition_point(|e| e.time.unwrap_or(f64::MAX) < time); + self.events.insert(pos, event); + } + + /// Returns the time of the earliest event, if any. + #[inline] + pub fn peek_time(&self) -> Option { + self.events.first().and_then(|e| e.time) + } + + /// Removes and returns the earliest event. + #[inline] + pub fn pop_front(&mut self) -> Option { + if self.events.is_empty() { + None + } else { + Some(self.events.remove(0)) + } + } + + /// Returns the number of scheduled events. + #[inline] + pub fn len(&self) -> usize { + self.events.len() + } + + /// Returns true if no events are scheduled. + #[inline] + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + /// Removes all scheduled events. + pub fn clear(&mut self) { + self.events.clear(); + } +} + +impl Default for Schedule { + fn default() -> Self { + Self::new() + } +} diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..1ce6789 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,112 @@ +//! Audio engine telemetry. Native only. + +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Instant; + +const LOAD_SCALE: f32 = 1_000_000.0; // fixed-point for atomic float storage +const DEFAULT_SMOOTHING: f32 = 0.9; + +/// Measures DSP load as ratio of processing time to buffer time. +/// +/// Thread-safe via atomics. Load of 1.0 means using all available time. +pub struct ProcessLoadMeasurer { + buffer_time_ns: AtomicU64, + load_fixed: AtomicU32, + smoothing: f32, +} + +impl Default for ProcessLoadMeasurer { + fn default() -> Self { + Self::new(DEFAULT_SMOOTHING) + } +} + +impl ProcessLoadMeasurer { + /// Creates a new measurer. Smoothing in [0.0, 0.99]: higher = slower response. + pub fn new(smoothing: f32) -> Self { + Self { + buffer_time_ns: AtomicU64::new(0), + load_fixed: AtomicU32::new(0), + smoothing: smoothing.clamp(0.0, 0.99), + } + } + + pub fn set_buffer_time(&self, ns: u64) { + self.buffer_time_ns.store(ns, Ordering::Relaxed); + } + + /// Returns a timer that records elapsed time on drop. + pub fn start_timer(&self) -> ScopedTimer<'_> { + ScopedTimer { + measurer: self, + start: Instant::now(), + } + } + + pub fn record_sample(&self, elapsed_ns: u64) { + let buffer_ns = self.buffer_time_ns.load(Ordering::Relaxed); + if buffer_ns == 0 { + return; + } + + let instant_load = (elapsed_ns as f64 / buffer_ns as f64).min(2.0) as f32; + let old_fixed = self.load_fixed.load(Ordering::Relaxed); + let old_load = old_fixed as f32 / LOAD_SCALE; + let new_load = self.smoothing * old_load + (1.0 - self.smoothing) * instant_load; + let new_fixed = (new_load * LOAD_SCALE) as u32; + + self.load_fixed.store(new_fixed, Ordering::Relaxed); + } + + pub fn get_load(&self) -> f32 { + self.load_fixed.load(Ordering::Relaxed) as f32 / LOAD_SCALE + } + + pub fn reset(&self) { + self.load_fixed.store(0, Ordering::Relaxed); + } +} + +/// RAII timer that records elapsed time on drop. +pub struct ScopedTimer<'a> { + measurer: &'a ProcessLoadMeasurer, + start: Instant, +} + +impl Drop for ScopedTimer<'_> { + fn drop(&mut self) { + let elapsed = self.start.elapsed().as_nanos() as u64; + self.measurer.record_sample(elapsed); + } +} + +/// Aggregated engine metrics. All fields atomic for cross-thread access. +pub struct EngineMetrics { + pub load: ProcessLoadMeasurer, + pub active_voices: AtomicU32, + pub peak_voices: AtomicU32, + pub schedule_depth: AtomicU32, + pub sample_pool_bytes: AtomicU64, +} + +impl Default for EngineMetrics { + fn default() -> Self { + Self { + load: ProcessLoadMeasurer::default(), + active_voices: AtomicU32::new(0), + peak_voices: AtomicU32::new(0), + schedule_depth: AtomicU32::new(0), + sample_pool_bytes: AtomicU64::new(0), + } + } +} + +impl EngineMetrics { + pub fn reset_peak_voices(&self) { + self.peak_voices.store(0, Ordering::Relaxed); + } + + pub fn sample_pool_mb(&self) -> f32 { + self.sample_pool_bytes.load(Ordering::Relaxed) as f32 / (1024.0 * 1024.0) + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..875bfcb --- /dev/null +++ b/src/types.rs @@ -0,0 +1,168 @@ +use std::str::FromStr; + +pub const BLOCK_SIZE: usize = 128; +pub const CHANNELS: usize = 2; +pub const MAX_VOICES: usize = 32; +pub const MAX_EVENTS: usize = 64; +pub const MAX_ORBITS: usize = 8; + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum Source { + #[default] + Tri, + Sine, + Saw, + Zaw, + Pulse, + Pulze, + White, + Pink, + Brown, + Sample, // Native: disk-loaded samples via FileSource + WebSample, // Web: inline PCM from JavaScript + LiveInput, // Live audio input (microphone, line-in) + PlModal, + PlVa, + PlWs, + PlFm, + PlGrain, + PlAdd, + PlWt, + PlChord, + PlSwarm, + PlNoise, + PlBass, + PlSnare, + PlHat, +} + +impl Source { + pub fn is_plaits_percussion(&self) -> bool { + matches!(self, Self::PlBass | Self::PlSnare | Self::PlHat) + } +} + +impl FromStr for Source { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "triangle" | "tri" => Ok(Self::Tri), + "sine" => Ok(Self::Sine), + "sawtooth" | "saw" => Ok(Self::Saw), + "zawtooth" | "zaw" => Ok(Self::Zaw), + "pulse" | "square" => Ok(Self::Pulse), + "pulze" | "zquare" => Ok(Self::Pulze), + "white" => Ok(Self::White), + "pink" => Ok(Self::Pink), + "brown" => Ok(Self::Brown), + "sample" => Ok(Self::Sample), + "websample" => Ok(Self::WebSample), + "live" | "livein" | "mic" => Ok(Self::LiveInput), + "plmodal" | "modal" => Ok(Self::PlModal), + "plva" | "va" | "analog" => Ok(Self::PlVa), + "plws" | "ws" | "waveshape" => Ok(Self::PlWs), + "plfm" | "fm2" => Ok(Self::PlFm), + "plgrain" | "grain" => Ok(Self::PlGrain), + "pladd" | "additive" => Ok(Self::PlAdd), + "plwt" | "wavetable" => Ok(Self::PlWt), + "plchord" | "chord" => Ok(Self::PlChord), + "plswarm" | "swarm" => Ok(Self::PlSwarm), + "plnoise" | "pnoise" => Ok(Self::PlNoise), + "plbass" | "bass" | "kick" => Ok(Self::PlBass), + "plsnare" | "snare" => Ok(Self::PlSnare), + "plhat" | "hat" | "hihat" => Ok(Self::PlHat), + _ => Err(()), + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum FilterSlope { + #[default] + Db12, + Db24, + Db48, +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum LfoShape { + #[default] + Sine, + Tri, + Saw, + Square, + Sh, +} + +impl FromStr for LfoShape { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "sine" | "sin" => Ok(Self::Sine), + "tri" | "triangle" => Ok(Self::Tri), + "saw" | "sawtooth" => Ok(Self::Saw), + "square" | "sq" => Ok(Self::Square), + "sh" | "sah" | "random" => Ok(Self::Sh), + _ => Err(()), + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum DelayType { + #[default] + Standard, + PingPong, + Tape, + Multitap, +} + +impl FromStr for DelayType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "standard" | "std" | "0" => Ok(Self::Standard), + "pingpong" | "pp" | "1" => Ok(Self::PingPong), + "tape" | "2" => Ok(Self::Tape), + "multitap" | "multi" | "3" => Ok(Self::Multitap), + _ => Err(()), + } + } +} + +impl FromStr for FilterSlope { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "12db" | "0" => Ok(Self::Db12), + "24db" | "1" => Ok(Self::Db24), + "48db" | "2" => Ok(Self::Db48), + _ => Err(()), + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum FilterType { + Lowpass, + Highpass, + Bandpass, + Notch, + Allpass, + Peaking, + Lowshelf, + Highshelf, +} + +pub fn midi2freq(note: f32) -> f32 { + 2.0_f32.powf((note - 69.0) / 12.0) * 440.0 +} + +pub fn freq2midi(freq: f32) -> f32 { + let safe_freq = freq.max(0.001); + 69.0 + 12.0 * (safe_freq / 440.0).log2() +} diff --git a/src/voice/mod.rs b/src/voice/mod.rs new file mode 100644 index 0000000..1a0a207 --- /dev/null +++ b/src/voice/mod.rs @@ -0,0 +1,462 @@ +//! Voice - the core synthesis unit. + +mod params; +mod source; + +pub use params::VoiceParams; + +use std::f32::consts::PI; + +use crate::effects::{crush, distort, fold, wrap, Chorus, Coarse, Flanger, Lag, Phaser}; +use crate::envelope::Adsr; +use crate::fastmath::{cosf, exp2f, sinf}; +use crate::filter::FilterState; +use crate::noise::{BrownNoise, PinkNoise}; +use crate::oscillator::Phasor; +use crate::plaits::PlaitsEngine; +use crate::sample::{FileSource, SampleInfo, WebSampleSource}; +use crate::types::{FilterSlope, FilterType, BLOCK_SIZE, CHANNELS}; + +fn apply_filter( + signal: f32, + filter: &mut FilterState, + ftype: FilterType, + q: f32, + num_stages: usize, + sr: f32, +) -> f32 { + let mut out = signal; + for stage in 0..num_stages { + out = filter.biquads[stage].process(out, ftype, filter.cutoff, q, sr); + } + out +} + +pub struct Voice { + pub params: VoiceParams, + pub phasor: Phasor, + pub spread_phasors: [Phasor; 7], + pub adsr: Adsr, + pub lp_adsr: Adsr, + pub hp_adsr: Adsr, + pub bp_adsr: Adsr, + pub lp: FilterState, + pub hp: FilterState, + pub bp: FilterState, + // Modulation + pub pitch_adsr: Adsr, + pub fm_adsr: Adsr, + pub vib_lfo: Phasor, + pub fm_phasor: Phasor, + pub am_lfo: Phasor, + pub rm_lfo: Phasor, + pub glide_lag: Lag, + pub current_freq: f32, + // Noise + pub pink_noise: PinkNoise, + pub brown_noise: BrownNoise, + // Sample playback (native) + pub file_source: Option, + // Sample playback (web) + pub web_sample: Option, + // Effects + pub phaser: Phaser, + pub flanger: Flanger, + pub chorus: Chorus, + pub coarse: Coarse, + + pub time: f32, + pub ch: [f32; CHANNELS], + pub spread_side: f32, + pub sr: f32, + pub lag_unit: f32, + pub(super) seed: u32, + + // Plaits engines + pub(super) plaits_engine: Option, + pub(super) plaits_out: [f32; BLOCK_SIZE], + pub(super) plaits_aux: [f32; BLOCK_SIZE], + pub(super) plaits_idx: usize, + pub(super) plaits_prev_gate: bool, +} + +impl Default for Voice { + fn default() -> Self { + let sr = 44100.0; + Self { + params: VoiceParams::default(), + phasor: Phasor::default(), + spread_phasors: std::array::from_fn(|i| { + let mut p = Phasor::default(); + p.phase = i as f32 / 7.0; + p + }), + adsr: Adsr::default(), + lp_adsr: Adsr::default(), + hp_adsr: Adsr::default(), + bp_adsr: Adsr::default(), + lp: FilterState::default(), + hp: FilterState::default(), + bp: FilterState::default(), + pitch_adsr: Adsr::default(), + fm_adsr: Adsr::default(), + vib_lfo: Phasor::default(), + fm_phasor: Phasor::default(), + am_lfo: Phasor::default(), + rm_lfo: Phasor::default(), + glide_lag: Lag::default(), + current_freq: 330.0, + pink_noise: PinkNoise::default(), + brown_noise: BrownNoise::default(), + file_source: None, + web_sample: None, + phaser: Phaser::default(), + flanger: Flanger::default(), + chorus: Chorus::default(), + coarse: Coarse::default(), + time: 0.0, + ch: [0.0; CHANNELS], + spread_side: 0.0, + sr, + lag_unit: sr / 10.0, + seed: 123456789, + plaits_engine: None, + plaits_out: [0.0; BLOCK_SIZE], + plaits_aux: [0.0; BLOCK_SIZE], + plaits_idx: BLOCK_SIZE, + plaits_prev_gate: false, + } + } +} + +impl Clone for Voice { + fn clone(&self) -> Self { + Self { + params: self.params, + phasor: self.phasor, + spread_phasors: self.spread_phasors, + adsr: self.adsr, + lp_adsr: self.lp_adsr, + hp_adsr: self.hp_adsr, + bp_adsr: self.bp_adsr, + lp: self.lp, + hp: self.hp, + bp: self.bp, + pitch_adsr: self.pitch_adsr, + fm_adsr: self.fm_adsr, + vib_lfo: self.vib_lfo, + fm_phasor: self.fm_phasor, + am_lfo: self.am_lfo, + rm_lfo: self.rm_lfo, + glide_lag: self.glide_lag, + current_freq: self.current_freq, + pink_noise: self.pink_noise, + brown_noise: self.brown_noise, + file_source: self.file_source, + web_sample: self.web_sample, + phaser: self.phaser, + flanger: self.flanger, + chorus: self.chorus, + coarse: self.coarse, + time: self.time, + ch: self.ch, + spread_side: self.spread_side, + sr: self.sr, + lag_unit: self.lag_unit, + seed: self.seed, + plaits_engine: None, + plaits_out: [0.0; BLOCK_SIZE], + plaits_aux: [0.0; BLOCK_SIZE], + plaits_idx: BLOCK_SIZE, + plaits_prev_gate: false, + } + } +} + +impl Voice { + pub(super) fn rand(&mut self) -> f32 { + self.seed = self.seed.wrapping_mul(1103515245).wrapping_add(12345); + ((self.seed >> 16) & 0x7fff) as f32 / 32767.0 + } + + pub(super) fn white(&mut self) -> f32 { + self.rand() * 2.0 - 1.0 + } + + fn compute_freq(&mut self, isr: f32) -> f32 { + let mut freq = self.params.freq; + + // Detune (cents offset) + if self.params.detune != 0.0 { + freq *= exp2f(self.params.detune / 1200.0); + } + + // Speed multiplier + freq *= self.params.speed; + + // Glide + if let Some(glide_time) = self.params.glide { + freq = self.glide_lag.update(freq, glide_time, self.lag_unit); + } + + // FM synthesis + if self.params.fm > 0.0 { + let mut fm_amount = self.params.fm; + if self.params.fm_env_active { + let env = self.fm_adsr.update( + self.time, + self.params.gate, + self.params.fma, + self.params.fmd, + self.params.fms, + self.params.fmr, + ); + fm_amount = self.params.fme * env * fm_amount + fm_amount; + } + let mod_freq = freq * self.params.fmh; + let mod_gain = mod_freq * fm_amount; + let modulator = self.fm_phasor.lfo(self.params.fmshape, mod_freq, isr); + freq += modulator * mod_gain; + } + + // Pitch envelope + if self.params.pitch_env_active && self.params.penv != 0.0 { + let env = self.pitch_adsr.update( + self.time, + 1.0, + self.params.patt, + self.params.pdec, + self.params.psus, + self.params.prel, + ); + let env_adj = if self.params.psus == 1.0 { + env - 1.0 + } else { + env + }; + freq *= exp2f(env_adj * self.params.penv / 12.0); + } + + // Vibrato + if self.params.vib > 0.0 && self.params.vibmod > 0.0 { + let mod_val = self.vib_lfo.lfo(self.params.vibshape, self.params.vib, isr); + freq *= exp2f(mod_val * self.params.vibmod / 12.0); + } + + self.current_freq = freq; + freq + } + + fn num_stages(&self) -> usize { + match self.params.ftype { + FilterSlope::Db12 => 1, + FilterSlope::Db24 => 2, + FilterSlope::Db48 => 4, + } + } + + pub fn process( + &mut self, + isr: f32, + pool: &[f32], + samples: &[SampleInfo], + web_pcm: &[f32], + sample_idx: usize, + live_input: &[f32], + ) -> bool { + let env = self.adsr.update( + self.time, + self.params.gate, + self.params.attack, + self.params.decay, + self.params.sustain, + self.params.release, + ); + if self.adsr.is_off() { + return false; + } + + let freq = self.compute_freq(isr); + if !self.run_source(freq, isr, pool, samples, web_pcm, sample_idx, live_input) { + return false; + } + + // Update filter envelopes + if let Some(lpf) = self.params.lpf { + self.lp.cutoff = lpf; + if self.params.lp_env_active { + let lp_env = self.lp_adsr.update( + self.time, + self.params.gate, + self.params.lpa, + self.params.lpd, + self.params.lps, + self.params.lpr, + ); + self.lp.cutoff = self.params.lpe * lp_env * lpf + lpf; + } + } + if let Some(hpf) = self.params.hpf { + self.hp.cutoff = hpf; + if self.params.hp_env_active { + let hp_env = self.hp_adsr.update( + self.time, + self.params.gate, + self.params.hpa, + self.params.hpd, + self.params.hps, + self.params.hpr, + ); + self.hp.cutoff = self.params.hpe * hp_env * hpf + hpf; + } + } + if let Some(bpf) = self.params.bpf { + self.bp.cutoff = bpf; + if self.params.bp_env_active { + let bp_env = self.bp_adsr.update( + self.time, + self.params.gate, + self.params.bpa, + self.params.bpd, + self.params.bps, + self.params.bpr, + ); + self.bp.cutoff = self.params.bpe * bp_env * bpf + bpf; + } + } + + // Pre-filter gain + self.ch[0] *= self.params.gain * self.params.velocity; + + // Apply filters (LP -> HP -> BP) + let num_stages = self.num_stages(); + if self.params.lpf.is_some() { + self.ch[0] = apply_filter( + self.ch[0], + &mut self.lp, + FilterType::Lowpass, + self.params.lpq, + num_stages, + self.sr, + ); + } + if self.params.hpf.is_some() { + self.ch[0] = apply_filter( + self.ch[0], + &mut self.hp, + FilterType::Highpass, + self.params.hpq, + num_stages, + self.sr, + ); + } + if self.params.bpf.is_some() { + self.ch[0] = apply_filter( + self.ch[0], + &mut self.bp, + FilterType::Bandpass, + self.params.bpq, + num_stages, + self.sr, + ); + } + + // Distortion effects + if let Some(coarse_factor) = self.params.coarse { + self.ch[0] = self.coarse.process(self.ch[0], coarse_factor); + } + if let Some(crush_bits) = self.params.crush { + self.ch[0] = crush(self.ch[0], crush_bits); + } + if let Some(fold_amount) = self.params.fold { + self.ch[0] = fold(self.ch[0], fold_amount); + } + if let Some(wrap_amount) = self.params.wrap { + self.ch[0] = wrap(self.ch[0], wrap_amount); + } + if let Some(dist_amount) = self.params.distort { + self.ch[0] = distort(self.ch[0], dist_amount, self.params.distortvol); + } + + // AM modulation + if self.params.am > 0.0 { + let modulator = self.am_lfo.lfo(self.params.amshape, self.params.am, isr); + let depth = self.params.amdepth.clamp(0.0, 1.0); + self.ch[0] *= 1.0 + modulator * depth; + } + + // Ring modulation + if self.params.rm > 0.0 { + let modulator = self.rm_lfo.lfo(self.params.rmshape, self.params.rm, isr); + let depth = self.params.rmdepth.clamp(0.0, 1.0); + self.ch[0] *= (1.0 - depth) + modulator * depth; + } + + // Phaser + if self.params.phaser > 0.0 { + self.ch[0] = self.phaser.process( + self.ch[0], + self.params.phaser, + self.params.phaserdepth, + self.params.phasercenter, + self.params.phasersweep, + self.sr, + isr, + ); + } + + // Flanger + if self.params.flanger > 0.0 { + self.ch[0] = self.flanger.process( + self.ch[0], + self.params.flanger, + self.params.flangerdepth, + self.params.flangerfeedback, + self.sr, + isr, + ); + } + + // Apply gain envelope and postgain + self.ch[0] *= env * self.params.postgain; + + // Restore stereo for spread mode + if self.params.spread > 0.0 { + let side = self.spread_side * env * self.params.postgain; + self.ch[1] = self.ch[0] - side; + self.ch[0] += side; + } else { + self.ch[1] = self.ch[0]; + } + + // Chorus + if self.params.chorus > 0.0 { + let stereo = self.chorus.process( + self.ch[0], + self.ch[1], + self.params.chorus, + self.params.chorusdepth, + self.params.chorusdelay, + self.sr, + isr, + ); + self.ch[0] = stereo[0]; + self.ch[1] = stereo[1]; + } + + // Panning + if self.params.pan != 0.5 { + let pan_pos = self.params.pan * PI / 2.0; + self.ch[0] *= cosf(pan_pos); + self.ch[1] *= sinf(pan_pos); + } + + self.time += isr; + if let Some(dur) = self.params.duration { + if dur > 0.0 && self.time > dur { + self.params.gate = 0.0; + } + } + true + } +} diff --git a/src/voice/params.rs b/src/voice/params.rs new file mode 100644 index 0000000..7bc0bfb --- /dev/null +++ b/src/voice/params.rs @@ -0,0 +1,405 @@ +//! Voice parameters - pure data structure for synthesis configuration. +//! +//! This module contains [`VoiceParams`], which holds all parameters that control +//! a single voice's sound. Parameters are grouped by function: +//! +//! - **Core** - frequency, gain, panning, gate +//! - **Oscillator** - sound source, pulse width, spread, waveshaping +//! - **Amplitude Envelope** - ADSR for volume +//! - **Filters** - lowpass, highpass, bandpass with optional envelopes +//! - **Pitch Modulation** - glide, pitch envelope, vibrato, FM +//! - **Amplitude Modulation** - AM, ring modulation +//! - **Effects** - phaser, flanger, chorus, distortion +//! - **Routing** - orbit assignment, effect sends + +use crate::oscillator::PhaseShape; +use crate::types::{DelayType, FilterSlope, LfoShape, Source}; + +/// All parameters that control a voice's sound generation. +/// +/// This is a pure data structure with no methods beyond [`Default`]. +/// The actual signal processing happens in [`Voice`](super::Voice). +#[derive(Clone, Copy)] +pub struct VoiceParams { + // ───────────────────────────────────────────────────────────────────── + // Core + // ───────────────────────────────────────────────────────────────────── + /// Base frequency in Hz. + pub freq: f32, + /// Pitch offset in cents (1/100th of a semitone). + pub detune: f32, + /// Playback speed multiplier (also affects pitch for samples). + pub speed: f32, + /// Pre-filter gain (0.0 to 1.0+). + pub gain: f32, + /// MIDI velocity (0.0 to 1.0), multiplied with gain. + pub velocity: f32, + /// Post-envelope gain (0.0 to 1.0+). + pub postgain: f32, + /// Stereo pan position (0.0 = left, 0.5 = center, 1.0 = right). + pub pan: f32, + /// Gate signal (> 0.0 = note on, 0.0 = note off). + pub gate: f32, + /// Optional note duration in seconds. Voice releases when exceeded. + pub duration: Option, + + // ───────────────────────────────────────────────────────────────────── + // Oscillator + // ───────────────────────────────────────────────────────────────────── + /// Sound source type (oscillator waveform, sample, or Plaits engine). + pub sound: Source, + /// Pulse width for pulse/square waves (0.0 to 1.0). + pub pw: f32, + /// Unison spread amount in cents. Enables 7-voice supersaw when > 0. + pub spread: f32, + /// Phase shaping parameters for waveform modification. + pub shape: PhaseShape, + /// Harmonics control for Plaits engines (0.0 to 1.0). + pub harmonics: f32, + /// Timbre control for Plaits engines (0.0 to 1.0). + pub timbre: f32, + /// Morph control for Plaits engines (0.0 to 1.0). + pub morph: f32, + /// Sample slice/cut index for sample playback. + pub cut: Option, + + // ───────────────────────────────────────────────────────────────────── + // Amplitude Envelope (ADSR) + // ───────────────────────────────────────────────────────────────────── + /// Attack time in seconds. + pub attack: f32, + /// Decay time in seconds. + pub decay: f32, + /// Sustain level (0.0 to 1.0). + pub sustain: f32, + /// Release time in seconds. + pub release: f32, + + // ───────────────────────────────────────────────────────────────────── + // Lowpass Filter + // ───────────────────────────────────────────────────────────────────── + /// Lowpass cutoff frequency in Hz. `None` = filter bypassed. + pub lpf: Option, + /// Lowpass resonance/Q (0.0 to 1.0). + pub lpq: f32, + /// Lowpass envelope depth multiplier. + pub lpe: f32, + /// Lowpass envelope attack time. + pub lpa: f32, + /// Lowpass envelope decay time. + pub lpd: f32, + /// Lowpass envelope sustain level. + pub lps: f32, + /// Lowpass envelope release time. + pub lpr: f32, + /// Enable lowpass filter envelope modulation. + pub lp_env_active: bool, + + // ───────────────────────────────────────────────────────────────────── + // Highpass Filter + // ───────────────────────────────────────────────────────────────────── + /// Highpass cutoff frequency in Hz. `None` = filter bypassed. + pub hpf: Option, + /// Highpass resonance/Q (0.0 to 1.0). + pub hpq: f32, + /// Highpass envelope depth multiplier. + pub hpe: f32, + /// Highpass envelope attack time. + pub hpa: f32, + /// Highpass envelope decay time. + pub hpd: f32, + /// Highpass envelope sustain level. + pub hps: f32, + /// Highpass envelope release time. + pub hpr: f32, + /// Enable highpass filter envelope modulation. + pub hp_env_active: bool, + + // ───────────────────────────────────────────────────────────────────── + // Bandpass Filter + // ───────────────────────────────────────────────────────────────────── + /// Bandpass center frequency in Hz. `None` = filter bypassed. + pub bpf: Option, + /// Bandpass resonance/Q (0.0 to 1.0). + pub bpq: f32, + /// Bandpass envelope depth multiplier. + pub bpe: f32, + /// Bandpass envelope attack time. + pub bpa: f32, + /// Bandpass envelope decay time. + pub bpd: f32, + /// Bandpass envelope sustain level. + pub bps: f32, + /// Bandpass envelope release time. + pub bpr: f32, + /// Enable bandpass filter envelope modulation. + pub bp_env_active: bool, + + // ───────────────────────────────────────────────────────────────────── + // Filter Slope + // ───────────────────────────────────────────────────────────────────── + /// Filter slope (12/24/48 dB per octave) for all filters. + pub ftype: FilterSlope, + + // ───────────────────────────────────────────────────────────────────── + // Glide (Portamento) + // ───────────────────────────────────────────────────────────────────── + /// Glide time in seconds. `None` = no glide. + pub glide: Option, + + // ───────────────────────────────────────────────────────────────────── + // Pitch Envelope + // ───────────────────────────────────────────────────────────────────── + /// Pitch envelope depth in semitones. + pub penv: f32, + /// Pitch envelope attack time. + pub patt: f32, + /// Pitch envelope decay time. + pub pdec: f32, + /// Pitch envelope sustain level. + pub psus: f32, + /// Pitch envelope release time. + pub prel: f32, + /// Enable pitch envelope modulation. + pub pitch_env_active: bool, + + // ───────────────────────────────────────────────────────────────────── + // Vibrato + // ───────────────────────────────────────────────────────────────────── + /// Vibrato LFO rate in Hz. + pub vib: f32, + /// Vibrato depth in semitones. + pub vibmod: f32, + /// Vibrato LFO waveform. + pub vibshape: LfoShape, + + // ───────────────────────────────────────────────────────────────────── + // FM Synthesis + // ───────────────────────────────────────────────────────────────────── + /// FM modulation index (depth). + pub fm: f32, + /// FM harmonic ratio (modulator freq = carrier freq * fmh). + pub fmh: f32, + /// FM modulator waveform. + pub fmshape: LfoShape, + /// FM envelope depth multiplier. + pub fme: f32, + /// FM envelope attack time. + pub fma: f32, + /// FM envelope decay time. + pub fmd: f32, + /// FM envelope sustain level. + pub fms: f32, + /// FM envelope release time. + pub fmr: f32, + /// Enable FM envelope modulation. + pub fm_env_active: bool, + + // ───────────────────────────────────────────────────────────────────── + // Amplitude Modulation + // ───────────────────────────────────────────────────────────────────── + /// AM LFO rate in Hz. + pub am: f32, + /// AM depth (0.0 to 1.0). + pub amdepth: f32, + /// AM LFO waveform. + pub amshape: LfoShape, + + // ───────────────────────────────────────────────────────────────────── + // Ring Modulation + // ───────────────────────────────────────────────────────────────────── + /// Ring modulator frequency in Hz. + pub rm: f32, + /// Ring modulation depth (0.0 to 1.0). + pub rmdepth: f32, + /// Ring modulator waveform. + pub rmshape: LfoShape, + + // ───────────────────────────────────────────────────────────────────── + // Phaser + // ───────────────────────────────────────────────────────────────────── + /// Phaser LFO rate in Hz. 0 = bypassed. + pub phaser: f32, + /// Phaser depth/feedback (0.0 to 1.0). + pub phaserdepth: f32, + /// Phaser sweep range in Hz. + pub phasersweep: f32, + /// Phaser center frequency in Hz. + pub phasercenter: f32, + + // ───────────────────────────────────────────────────────────────────── + // Flanger + // ───────────────────────────────────────────────────────────────────── + /// Flanger LFO rate in Hz. 0 = bypassed. + pub flanger: f32, + /// Flanger depth (0.0 to 1.0). + pub flangerdepth: f32, + /// Flanger feedback amount (0.0 to 1.0). + pub flangerfeedback: f32, + + // ───────────────────────────────────────────────────────────────────── + // Chorus + // ───────────────────────────────────────────────────────────────────── + /// Chorus LFO rate in Hz. 0 = bypassed. + pub chorus: f32, + /// Chorus depth/modulation amount (0.0 to 1.0). + pub chorusdepth: f32, + /// Chorus base delay time in ms. + pub chorusdelay: f32, + + // ───────────────────────────────────────────────────────────────────── + // Distortion + // ───────────────────────────────────────────────────────────────────── + /// Coarse sample rate reduction factor. `None` = bypassed. + pub coarse: Option, + /// Bit crush depth (bits). `None` = bypassed. + pub crush: Option, + /// Wavefolding amount. `None` = bypassed. + pub fold: Option, + /// Wavewrapping amount. `None` = bypassed. + pub wrap: Option, + /// Distortion/saturation amount. `None` = bypassed. + pub distort: Option, + /// Distortion output volume compensation. + pub distortvol: f32, + + // ───────────────────────────────────────────────────────────────────── + // Routing / Sends + // ───────────────────────────────────────────────────────────────────── + /// Orbit index for effect bus routing (0 to MAX_ORBITS-1). + pub orbit: usize, + /// Delay send level (0.0 to 1.0). + pub delay: f32, + /// Delay time in seconds (overrides orbit default). + pub delaytime: f32, + /// Delay feedback amount (overrides orbit default). + pub delayfeedback: f32, + /// Delay type (overrides orbit default). + pub delaytype: DelayType, + /// Reverb send level (0.0 to 1.0). + pub verb: f32, + /// Reverb decay time (overrides orbit default). + pub verbdecay: f32, + /// Reverb damping (overrides orbit default). + pub verbdamp: f32, + /// Reverb pre-delay in seconds. + pub verbpredelay: f32, + /// Reverb diffusion amount. + pub verbdiff: f32, + /// Comb filter send level (0.0 to 1.0). + pub comb: f32, + /// Comb filter frequency in Hz. + pub combfreq: f32, + /// Comb filter feedback amount. + pub combfeedback: f32, + /// Comb filter damping. + pub combdamp: f32, +} + +impl Default for VoiceParams { + fn default() -> Self { + Self { + freq: 330.0, + detune: 0.0, + speed: 1.0, + gain: 1.0, + velocity: 1.0, + postgain: 1.0, + pan: 0.5, + gate: 1.0, + duration: None, + sound: Source::Tri, + pw: 0.5, + spread: 0.0, + shape: PhaseShape::default(), + harmonics: 0.5, + timbre: 0.5, + morph: 0.5, + cut: None, + attack: 0.001, + decay: 0.0, + sustain: 1.0, + release: 0.005, + lpf: None, + lpq: 0.2, + lpe: 1.0, + lpa: 0.001, + lpd: 0.0, + lps: 1.0, + lpr: 0.005, + lp_env_active: false, + hpf: None, + hpq: 0.2, + hpe: 1.0, + hpa: 0.001, + hpd: 0.0, + hps: 1.0, + hpr: 0.005, + hp_env_active: false, + bpf: None, + bpq: 0.2, + bpe: 1.0, + bpa: 0.001, + bpd: 0.0, + bps: 1.0, + bpr: 0.005, + bp_env_active: false, + ftype: FilterSlope::Db12, + glide: None, + penv: 1.0, + patt: 0.001, + pdec: 0.0, + psus: 1.0, + prel: 0.005, + pitch_env_active: false, + vib: 0.0, + vibmod: 0.5, + vibshape: LfoShape::Sine, + fm: 0.0, + fmh: 1.0, + fmshape: LfoShape::Sine, + fme: 1.0, + fma: 0.001, + fmd: 0.0, + fms: 1.0, + fmr: 0.005, + fm_env_active: false, + am: 0.0, + amdepth: 0.5, + amshape: LfoShape::Sine, + rm: 0.0, + rmdepth: 1.0, + rmshape: LfoShape::Sine, + phaser: 0.0, + phaserdepth: 0.75, + phasersweep: 2000.0, + phasercenter: 1000.0, + flanger: 0.0, + flangerdepth: 0.5, + flangerfeedback: 0.5, + chorus: 0.0, + chorusdepth: 0.5, + chorusdelay: 25.0, + coarse: None, + crush: None, + fold: None, + wrap: None, + distort: None, + distortvol: 1.0, + orbit: 0, + delay: 0.0, + delaytime: 0.333, + delayfeedback: 0.6, + delaytype: DelayType::Standard, + verb: 0.0, + verbdecay: 0.75, + verbdamp: 0.95, + verbpredelay: 0.1, + verbdiff: 0.7, + comb: 0.0, + combfreq: 220.0, + combfeedback: 0.9, + combdamp: 0.1, + } + } +} diff --git a/src/voice/source.rs b/src/voice/source.rs new file mode 100644 index 0000000..1e5a2a6 --- /dev/null +++ b/src/voice/source.rs @@ -0,0 +1,213 @@ +//! Source generation - oscillators, samples, Plaits engines, spread mode. + +use crate::fastmath::exp2f; +use crate::oscillator::Phasor; +use crate::plaits::PlaitsEngine; +use crate::sample::SampleInfo; +use crate::types::{freq2midi, Source, BLOCK_SIZE, CHANNELS}; +use mi_plaits_dsp::engine::{EngineParameters, TriggerState}; + +use super::Voice; + +impl Voice { + #[inline] + pub(super) fn osc_at(&self, phasor: &Phasor, phase: f32) -> f32 { + match self.params.sound { + Source::Tri => phasor.tri_at(phase, &self.params.shape), + Source::Sine => phasor.sine_at(phase, &self.params.shape), + Source::Saw => phasor.saw_at(phase, &self.params.shape), + Source::Zaw => phasor.zaw_at(phase, &self.params.shape), + Source::Pulse => phasor.pulse_at(phase, self.params.pw, &self.params.shape), + Source::Pulze => phasor.pulze_at(phase, self.params.pw, &self.params.shape), + _ => 0.0, + } + } + + pub(super) fn run_source( + &mut self, + freq: f32, + isr: f32, + pool: &[f32], + samples: &[SampleInfo], + web_pcm: &[f32], + sample_idx: usize, + live_input: &[f32], + ) -> bool { + match self.params.sound { + Source::Sample => { + if let Some(ref mut fs) = self.file_source { + if let Some(info) = samples.get(fs.sample_idx) { + if fs.is_done(info) { + return false; + } + for c in 0..CHANNELS { + self.ch[c] = fs.update(pool, info, self.params.speed, c) * 0.2; + } + return true; + } + } + self.ch[0] = 0.0; + self.ch[1] = 0.0; + } + Source::WebSample => { + if let Some(ref mut ws) = self.web_sample { + if ws.is_done() { + return false; + } + for c in 0..CHANNELS { + self.ch[c] = ws.update(web_pcm, self.params.speed, c) * 0.2; + } + return true; + } + self.ch[0] = 0.0; + self.ch[1] = 0.0; + } + Source::LiveInput => { + let input_idx = sample_idx * CHANNELS; + for c in 0..CHANNELS { + let idx = input_idx + c; + self.ch[c] = live_input.get(idx).copied().unwrap_or(0.0) * 0.2; + } + } + Source::PlModal + | Source::PlVa + | Source::PlWs + | Source::PlFm + | Source::PlGrain + | Source::PlAdd + | Source::PlWt + | Source::PlChord + | Source::PlSwarm + | Source::PlNoise + | Source::PlBass + | Source::PlSnare + | Source::PlHat => { + if self.plaits_idx >= BLOCK_SIZE { + let need_new = self + .plaits_engine + .as_ref() + .is_none_or(|e| e.source() != self.params.sound); + if need_new { + let sample_rate = 1.0 / isr; + self.plaits_engine = Some(PlaitsEngine::new(self.params.sound, sample_rate)); + } + let engine = self.plaits_engine.as_mut().unwrap(); + + let trigger = if self.params.sound.is_plaits_percussion() { + TriggerState::Unpatched + } else { + let gate_high = self.params.gate > 0.5; + let t = if gate_high && !self.plaits_prev_gate { + TriggerState::RisingEdge + } else if gate_high { + TriggerState::High + } else { + TriggerState::Low + }; + self.plaits_prev_gate = gate_high; + t + }; + + let params = EngineParameters { + trigger, + note: freq2midi(freq), + timbre: self.params.timbre, + morph: self.params.morph, + harmonics: self.params.harmonics, + accent: self.params.velocity, + a0_normalized: 55.0 * isr, + }; + + let mut already_enveloped = false; + engine.render( + ¶ms, + &mut self.plaits_out, + &mut self.plaits_aux, + &mut already_enveloped, + ); + self.plaits_idx = 0; + } + + self.ch[0] = self.plaits_out[self.plaits_idx] * 0.2; + self.ch[1] = self.ch[0]; + self.plaits_idx += 1; + } + _ => { + let spread = self.params.spread; + if spread > 0.0 { + self.run_spread(freq, isr); + } else { + self.run_single_osc(freq, isr); + } + } + } + true + } + + fn run_spread(&mut self, freq: f32, isr: f32) { + let mut left = 0.0; + let mut right = 0.0; + const PAN: [f32; 3] = [0.3, 0.6, 0.9]; + + // Center oscillator + let phase_c = self.spread_phasors[3].phase; + let center = self.osc_at(&self.spread_phasors[3], phase_c); + self.spread_phasors[3].phase = (phase_c + freq * isr) % 1.0; + left += center; + right += center; + + // Symmetric pairs with parabolic detuning + stereo spread + for i in 1..=3 { + let detune_cents = (i * i) as f32 * self.params.spread; + let ratio_up = exp2f(detune_cents / 1200.0); + let ratio_down = exp2f(-detune_cents / 1200.0); + + let phase_up = self.spread_phasors[3 + i].phase; + let voice_up = self.osc_at(&self.spread_phasors[3 + i], phase_up); + self.spread_phasors[3 + i].phase = (phase_up + freq * ratio_up * isr) % 1.0; + + let phase_down = self.spread_phasors[3 - i].phase; + let voice_down = self.osc_at(&self.spread_phasors[3 - i], phase_down); + self.spread_phasors[3 - i].phase = (phase_down + freq * ratio_down * isr) % 1.0; + + let pan = PAN[i - 1]; + left += voice_down * (0.5 + pan * 0.5) + voice_up * (0.5 - pan * 0.5); + right += voice_up * (0.5 + pan * 0.5) + voice_down * (0.5 - pan * 0.5); + } + + // Store as mid/side - effects process mid, stereo restored later + let mid = (left + right) / 2.0; + let side = (left - right) / 2.0; + self.ch[0] = mid / 4.0 * 0.2; + self.spread_side = side / 4.0 * 0.2; + } + + fn run_single_osc(&mut self, freq: f32, isr: f32) { + self.ch[0] = match self.params.sound { + Source::Tri => self.phasor.tri_shaped(freq, isr, &self.params.shape) * 0.2, + Source::Sine => self.phasor.sine_shaped(freq, isr, &self.params.shape) * 0.2, + Source::Saw => self.phasor.saw_shaped(freq, isr, &self.params.shape) * 0.2, + Source::Zaw => self.phasor.zaw_shaped(freq, isr, &self.params.shape) * 0.2, + Source::Pulse => { + self.phasor + .pulse_shaped(freq, self.params.pw, isr, &self.params.shape) + * 0.2 + } + Source::Pulze => { + self.phasor + .pulze_shaped(freq, self.params.pw, isr, &self.params.shape) + * 0.2 + } + Source::White => self.white() * 0.2, + Source::Pink => { + let w = self.white(); + self.pink_noise.next(w) * 0.2 + } + Source::Brown => { + let w = self.white(); + self.brown_noise.next(w) * 0.2 + } + _ => 0.0, + }; + } +} diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 0000000..bcc5130 --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,385 @@ +//! WebAssembly FFI bindings for browser-based audio. +//! +//! Exposes the doux engine to JavaScript via a C-compatible interface. The host +//! (browser) and WASM module communicate through shared memory buffers. +//! +//! # Memory Layout +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Static Buffers (shared with JS via pointers) │ +//! ├─────────────────┬───────────────────────────────────────────────────┤ +//! │ OUTPUT │ Audio output buffer (BLOCK_SIZE × CHANNELS f32) │ +//! │ INPUT_BUFFER │ Live audio input (BLOCK_SIZE × CHANNELS f32) │ +//! │ EVENT_INPUT │ Command strings from JS (1024 bytes, null-term) │ +//! │ SAMPLE_BUFFER │ Staging area for sample uploads (16MB of f32) │ +//! │ FRAMEBUFFER │ Ring buffer for waveform visualization │ +//! └─────────────────┴───────────────────────────────────────────────────┘ +//! ``` +//! +//! # Typical Usage Flow +//! +//! ```text +//! JS WASM +//! ── ──── +//! 1. doux_init(sampleRate) → Create engine +//! 2. get_*_pointer() → Get buffer addresses +//! 3. Write command to EVENT_INPUT +//! 4. evaluate() → Parse & execute command +//! 5. [Optional] Write samples to SAMPLE_BUFFER +//! 6. load_sample(len, ch, freq) → Add to pool +//! 7. [Optional] Write mic input to INPUT_BUFFER +//! 8. dsp() → Process one block +//! 9. Read OUTPUT ← Get audio samples +//! 10. Repeat 3-9 in audio callback +//! ``` +//! +//! # Audio Worklet Integration +//! +//! In the browser, this typically runs in an AudioWorkletProcessor: +//! - `dsp()` is called each audio quantum (~128 samples) +//! - Output buffer is copied to the worklet's output +//! - Input buffer receives microphone data for live processing + +#![allow(static_mut_refs)] + +use crate::types::{Source, BLOCK_SIZE, CHANNELS}; +use crate::Engine; + +/// Maximum length of command strings from JavaScript. +const EVENT_INPUT_SIZE: usize = 1024; + +/// Ring buffer size for waveform visualization (~60fps at 48kHz stereo). +/// Calculation: floor(48000/60) × 2 channels × 4 (double-buffer headroom) = 6400 +const FRAMEBUFFER_SIZE: usize = 6400; + +// Global engine instance (single-threaded WASM environment) +static mut ENGINE: Option = None; + +// Shared memory buffers accessible from JavaScript +static mut OUTPUT: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS]; +static mut EVENT_INPUT: [u8; EVENT_INPUT_SIZE] = [0; EVENT_INPUT_SIZE]; +static mut FRAMEBUFFER: [f32; FRAMEBUFFER_SIZE] = [0.0; FRAMEBUFFER_SIZE]; +static mut FRAME_IDX: i32 = 0; + +/// Sample upload staging buffer (16MB = 4M floats). +/// JS decodes audio files and writes f32 samples here before calling `load_sample`. +const SAMPLE_BUFFER_SIZE: usize = 4_194_304; +static mut SAMPLE_BUFFER: [f32; SAMPLE_BUFFER_SIZE] = [0.0; SAMPLE_BUFFER_SIZE]; + +/// Live audio input buffer (microphone/line-in from Web Audio). +static mut INPUT_BUFFER: [f32; BLOCK_SIZE * CHANNELS] = [0.0; BLOCK_SIZE * CHANNELS]; + +// ============================================================================= +// Lifecycle +// ============================================================================= + +/// Initializes the audio engine at the given sample rate. +/// +/// Must be called once before any other functions. +#[no_mangle] +pub extern "C" fn doux_init(sample_rate: f32) { + unsafe { + ENGINE = Some(Engine::new(sample_rate)); + } +} + +// ============================================================================= +// Audio Processing +// ============================================================================= + +/// Processes one block of audio and updates the framebuffer. +/// +/// Call this from the AudioWorklet's `process()` method. Reads from +/// `INPUT_BUFFER`, writes to `OUTPUT`, and appends to `FRAMEBUFFER`. +#[no_mangle] +pub extern "C" fn dsp() { + unsafe { + if let Some(ref mut engine) = ENGINE { + engine.process_block(&mut OUTPUT, &SAMPLE_BUFFER, &INPUT_BUFFER); + + // Copy to ring buffer for visualization + let fb_len = FRAMEBUFFER.len() as i32; + for (i, &sample) in OUTPUT.iter().enumerate() { + let idx = (FRAME_IDX + i as i32) % fb_len; + FRAMEBUFFER[idx as usize] = sample; + } + FRAME_IDX = (FRAME_IDX + (BLOCK_SIZE * CHANNELS) as i32) % fb_len; + } + } +} + +// ============================================================================= +// Command Interface +// ============================================================================= + +/// Parses and executes the command string in `EVENT_INPUT`. +/// +/// The command should be written as a null-terminated UTF-8 string to the +/// buffer returned by `get_event_input_pointer()`. +/// +/// # Returns +/// +/// - Sample index if the command triggered a sample load +/// - `-1` on error or for commands that don't return a value +#[no_mangle] +pub extern "C" fn evaluate() -> i32 { + unsafe { + if let Some(ref mut engine) = ENGINE { + let len = EVENT_INPUT + .iter() + .position(|&b| b == 0) + .unwrap_or(EVENT_INPUT_SIZE); + if len == 0 { + return -1; + } + if let Ok(s) = core::str::from_utf8(&EVENT_INPUT[..len]) { + let result = engine.evaluate(s).map(|i| i as i32).unwrap_or(-1); + EVENT_INPUT[0] = 0; // Clear for next command + return result; + } + } + -1 + } +} + +// ============================================================================= +// Buffer Pointers (for JS interop) +// ============================================================================= + +/// Returns pointer to the audio output buffer. +#[no_mangle] +pub extern "C" fn get_output_pointer() -> *const f32 { + unsafe { OUTPUT.as_ptr() } +} + +/// Returns the length of the output buffer in samples. +#[no_mangle] +pub extern "C" fn get_output_len() -> usize { + BLOCK_SIZE * CHANNELS +} + +/// Returns mutable pointer to the event input buffer. +/// +/// Write null-terminated UTF-8 command strings here, then call `evaluate()`. +#[no_mangle] +pub extern "C" fn get_event_input_pointer() -> *mut u8 { + unsafe { EVENT_INPUT.as_mut_ptr() } +} + +/// Returns mutable pointer to the sample upload staging buffer. +/// +/// Write decoded f32 samples here, then call `load_sample()`. +#[no_mangle] +pub extern "C" fn get_sample_buffer_pointer() -> *mut f32 { + unsafe { SAMPLE_BUFFER.as_mut_ptr() } +} + +/// Returns the capacity of the sample buffer in floats. +#[no_mangle] +pub extern "C" fn get_sample_buffer_len() -> usize { + SAMPLE_BUFFER_SIZE +} + +/// Returns mutable pointer to the live audio input buffer. +/// +/// Write microphone/line-in samples here before calling `dsp()`. +#[no_mangle] +pub extern "C" fn get_input_buffer_pointer() -> *mut f32 { + unsafe { INPUT_BUFFER.as_mut_ptr() } +} + +/// Returns the length of the input buffer in samples. +#[no_mangle] +pub extern "C" fn get_input_buffer_len() -> usize { + BLOCK_SIZE * CHANNELS +} + +/// Returns pointer to the waveform visualization ring buffer. +#[no_mangle] +pub extern "C" fn get_framebuffer_pointer() -> *const f32 { + unsafe { FRAMEBUFFER.as_ptr() } +} + +/// Returns pointer to the current frame index in the ring buffer. +#[no_mangle] +pub extern "C" fn get_frame_pointer() -> *const i32 { + unsafe { &FRAME_IDX as *const i32 } +} + +// ============================================================================= +// Sample Loading +// ============================================================================= + +/// Loads sample data from the staging buffer into the engine's pool. +/// +/// # Parameters +/// +/// - `len`: Number of f32 samples in `SAMPLE_BUFFER` +/// - `channels`: Channel count (1 = mono, 2 = stereo) +/// - `freq`: Base frequency for pitch calculations +/// +/// # Returns +/// +/// Pool index on success, `-1` on failure. +#[no_mangle] +pub extern "C" fn load_sample(len: usize, channels: u8, freq: f32) -> i32 { + unsafe { + if let Some(ref mut engine) = ENGINE { + let samples = &SAMPLE_BUFFER[..len.min(SAMPLE_BUFFER_SIZE)]; + match engine.load_sample(samples, channels, freq) { + Some(idx) => idx as i32, + None => -1, + } + } else { + -1 + } + } +} + +/// Returns the number of samples loaded in the pool. +#[no_mangle] +pub extern "C" fn get_sample_count() -> usize { + unsafe { + if let Some(ref engine) = ENGINE { + engine.samples.len() + } else { + 0 + } + } +} + +// ============================================================================= +// Engine State +// ============================================================================= + +/// Returns the current engine time in seconds. +#[no_mangle] +pub extern "C" fn get_time() -> f64 { + unsafe { + if let Some(ref engine) = ENGINE { + engine.time + } else { + 0.0 + } + } +} + +/// Returns the engine's sample rate. +#[no_mangle] +pub extern "C" fn get_sample_rate() -> f32 { + unsafe { + if let Some(ref engine) = ENGINE { + engine.sr + } else { + 0.0 + } + } +} + +/// Returns the number of currently active voices. +#[no_mangle] +pub extern "C" fn get_active_voices() -> usize { + unsafe { + if let Some(ref engine) = ENGINE { + engine.active_voices + } else { + 0 + } + } +} + +/// Fades out all active voices smoothly. +#[no_mangle] +pub extern "C" fn hush() { + unsafe { + if let Some(ref mut engine) = ENGINE { + engine.hush(); + } + } +} + +/// Immediately silences all voices (may click). +#[no_mangle] +pub extern "C" fn panic() { + unsafe { + if let Some(ref mut engine) = ENGINE { + engine.panic(); + } + } +} + +// ============================================================================= +// Debug Helpers +// ============================================================================= + +/// Debug: reads a byte from the event input buffer. +#[no_mangle] +pub extern "C" fn debug_event_input_byte(idx: usize) -> u8 { + unsafe { EVENT_INPUT.get(idx).copied().unwrap_or(255) } +} + +/// Debug: returns the source type of a voice as an integer. +/// +/// Mapping: Tri=0, Sine=1, Saw=2, ... LiveInput=11, PlModal=12, etc. +/// Returns `-1` if voice index is invalid. +#[no_mangle] +pub extern "C" fn debug_voice_source(voice_idx: usize) -> i32 { + unsafe { + if let Some(ref engine) = ENGINE { + if voice_idx < engine.active_voices { + match engine.voices[voice_idx].params.sound { + Source::Tri => 0, + Source::Sine => 1, + Source::Saw => 2, + Source::Zaw => 3, + Source::Pulse => 4, + Source::Pulze => 5, + Source::White => 6, + Source::Pink => 7, + Source::Brown => 8, + Source::Sample => 9, + Source::WebSample => 10, + Source::LiveInput => 11, + Source::PlModal => 12, + Source::PlVa => 13, + Source::PlWs => 14, + Source::PlFm => 15, + Source::PlGrain => 16, + Source::PlAdd => 17, + Source::PlWt => 18, + Source::PlChord => 19, + Source::PlSwarm => 20, + Source::PlNoise => 21, + Source::PlBass => 22, + Source::PlSnare => 23, + Source::PlHat => 24, + } + } else { + -1 + } + } else { + -1 + } + } +} + +/// Debug: returns 1 if voice has a web sample attached, 0 otherwise. +#[no_mangle] +pub extern "C" fn debug_voice_has_web_sample(voice_idx: usize) -> i32 { + unsafe { + if let Some(ref engine) = ENGINE { + if voice_idx < engine.active_voices { + if engine.voices[voice_idx].web_sample.is_some() { + 1 + } else { + 0 + } + } else { + -1 + } + } else { + -1 + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..d82d772 --- /dev/null +++ b/website/package.json @@ -0,0 +1,24 @@ +{ + "name": "doux-website", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.1", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "mdsvex": "^0.12.3", + "svelte": "^5.16.0", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "type": "module", + "dependencies": { + "coi-serviceworker": "^0.1.7", + "lucide-svelte": "^0.562.0" + } +} diff --git a/website/src/app.css b/website/src/app.css new file mode 100644 index 0000000..acca41b --- /dev/null +++ b/website/src/app.css @@ -0,0 +1,298 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 13px; + line-height: 1.5; + color: #111; + background: #fff; +} + +a { + color: #000; + text-decoration: underline; +} + +a:hover { + color: #666; +} + +code { + font-family: inherit; + background: #f5f5f5; + padding: 2px 6px; + border: 1px solid #ccc; +} + +pre, +input, +textarea { + font-family: inherit; + font-size: 12px; + padding: 8px; + border: 1px solid #ccc; + background: #f5f5f5; + color: #111; + width: 100%; + outline: none; + resize: none; +} + +button { + font-family: inherit; + font-size: 12px; + background: #f5f5f5; + color: #111; + border: 1px solid #ccc; + padding: 8px 16px; + cursor: pointer; +} + +button:hover { + background: #eee; +} + +h1, +h2, +h3 { + font-weight: normal; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +h1 { + font-size: 14px; +} +h2 { + font-size: 13px; +} +h3 { + font-size: 12px; + margin-top: 1em; +} + +nav { + border-bottom: 1px solid #ccc; + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + right: 0; + background: #fff; + z-index: 50; +} + +nav h1 { + margin: 0; +} + +.mic-btn { + padding: 6px 12px; +} + +.mic-btn.mic-enabled { + background: #e8f5e8; + border-color: #8c8; + color: #2a5a2a; +} + +.mic-btn:disabled { + cursor: wait; + opacity: 0.7; +} + +.layout { + display: flex; + min-height: calc(100vh - 57px); + margin-top: 57px; +} + +.sidebar { + width: 160px; + border-right: 1px solid #ccc; + padding: 12px 0; + flex-shrink: 0; + position: sticky; + top: 57px; + height: calc(100vh - 57px); + overflow-y: auto; + align-self: flex-start; +} + +.sidebar-section { + padding: 8px 16px 8px; + font-size: 12px; + color: #000; + text-transform: uppercase; + letter-spacing: 0.15em; + border-bottom: 1px solid #ddd; + margin-bottom: 4px; +} + +.sidebar a { + display: block; + padding: 4px 16px; + text-decoration: none; + color: #666; +} + +.sidebar a:hover { + color: #000; + background: #f5f5f5; +} + +.content { + flex: 1; + padding: 0 20px 40px; +} + +.nav-scope { + flex: 1; + margin: 0 16px; +} + +.nav-scope .scope { + height: 32px; + border: 1px solid #ccc; + background: #fff; +} + +.scope canvas { + width: 100%; + height: 100%; + display: block; +} + + +.content section { + max-width: 700px; + margin: 0 auto; +} + +.content h2 { + padding: 12px 0; + margin: 0; +} + +.intro { + border-bottom: 1px solid #ccc; + padding-bottom: 1em; + margin-bottom: 1em; +} + +ul, +ol { + padding-left: 20px; +} + +li { + padding: 2px 0; +} + +.repl { + display: flex; + gap: 8px; + margin: 1em 0; +} + +.repl-editor { + flex: 1; + position: relative; +} + +.repl-editor textarea { + display: block; + background: transparent; + color: transparent; + caret-color: #111; + position: relative; + z-index: 1; +} + +.hl-pre { + position: absolute; + inset: 0; + margin: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.hl-slash { + color: #999; +} +.hl-command { + color: #000; + font-weight: bold; +} +.hl-number { + color: #c00; +} +.hl-comment { + color: #999; + font-style: italic; +} + +.repl-controls { + display: flex; +} + +.repl-controls button { + width: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes eval-flash { + from { + outline: 1px solid #999; + } + to { + outline: 1px solid transparent; + } +} + +.evaluated { + animation: eval-flash 0.3s; +} + +@media (max-width: 768px) { + .nav-scope { + position: fixed; + bottom: 48px; + left: 0; + right: 0; + margin: 0; + z-index: 100; + } + + .nav-scope .scope { + height: 48px; + border: none; + } + + .layout { + padding-bottom: 96px; + } + + .sidebar { + display: none; + } +} diff --git a/website/src/app.d.ts b/website/src/app.d.ts new file mode 100644 index 0000000..db1aa5d --- /dev/null +++ b/website/src/app.d.ts @@ -0,0 +1,13 @@ +/// + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/website/src/app.html b/website/src/app.html new file mode 100644 index 0000000..040262f --- /dev/null +++ b/website/src/app.html @@ -0,0 +1,16 @@ + + + + + + doux + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/website/src/content/am.md b/website/src/content/am.md new file mode 100644 index 0000000..8418114 --- /dev/null +++ b/website/src/content/am.md @@ -0,0 +1,37 @@ +--- +title: "Amplitude Modulation" +slug: "am" +group: "synthesis" +order: 109 +--- + + + +Amplitude modulation multiplies the signal by a modulating oscillator. The formula preserves the original signal at depth 0: signal *= 1.0 + modulator * depth. This creates sidebands at carrier ± modulator frequencies while keeping the carrier present. + + + +AM oscillator frequency in Hz. When set above 0, an LFO modulates the signal amplitude. + + + + + + + +Modulation depth (0-1). At 0, the signal is unchanged. At 1, the signal varies between 0 and 2x its amplitude. + + + + + + + +AM LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). + + + + diff --git a/website/src/content/bandpass.md b/website/src/content/bandpass.md new file mode 100644 index 0000000..696bd23 --- /dev/null +++ b/website/src/content/bandpass.md @@ -0,0 +1,69 @@ +--- +title: "Bandpass Filter" +slug: "bandpass" +group: "effects" +order: 112 +--- + + + +A bandpass filter attenuates frequencies outside a band around the center frequency. Each filter has its own ADSR envelope that modulates the center frequency. + + + +Center frequency in Hz. Frequencies outside the band are attenuated. + + + + + + + +Resonance (0-1). Higher values narrow the passband. + + + + + + + +Envelope amount. Positive values sweep the center up, negative values sweep down. + + + + + + + +Envelope attack time in seconds. + + + + + + + +Envelope decay time in seconds. + + + + + + + +Envelope sustain level (0-1). + + + + + + + +Envelope release time in seconds. + + + + diff --git a/website/src/content/basic.md b/website/src/content/basic.md new file mode 100644 index 0000000..c9366b4 --- /dev/null +++ b/website/src/content/basic.md @@ -0,0 +1,103 @@ +--- +title: "Basic" +slug: "basic" +group: "sources" +order: 0 +--- + + + +These sources provide fundamental waveforms that can be combined and manipulated to create complex sounds. They are inspired by classic substractive synthesizers. + + + +Pure sine wave. The simplest waveform with no harmonics. + + + + + + + + + +Triangle wave. The default source. Contains only odd harmonics with gentle rolloff. + + + + + + + + + +Band-limited sawtooth wave. Rich in harmonics, bright and buzzy. + + + + + + + + + +Naive sawtooth with no anti-aliasing. Cheaper but more aliasing artifacts than saw. + + + + + + + + + +Band-limited pulse wave. Hollow sound with only odd harmonics. Use /pw to control pulse width. + + + + + + + + + +Naive pulse with no anti-aliasing. Cheaper but more aliasing artifacts than pulse. + + + + + + + + + +White noise. Equal energy at all frequencies. + + + + + + + + + +Pink noise (1/f). Equal energy per octave, more natural sounding. + + + + + + + + + +Brown/red noise (1/f^2). Deep rumbling, heavily weighted toward low frequencies. + + + + + + diff --git a/website/src/content/chorus.md b/website/src/content/chorus.md new file mode 100644 index 0000000..cd08ab5 --- /dev/null +++ b/website/src/content/chorus.md @@ -0,0 +1,43 @@ +--- +title: "Chorus" +slug: "chorus" +group: "effects" +order: 202 +--- + + + +A rich chorus effect that adds depth and movement to any sound. + + + +Chorus LFO rate in Hz. + + + + + + + + + +Chorus modulation depth (0-1). + + + + + + + + + +Chorus base delay time in milliseconds. + + + + + + diff --git a/website/src/content/comb.md b/website/src/content/comb.md new file mode 100644 index 0000000..78e675d --- /dev/null +++ b/website/src/content/comb.md @@ -0,0 +1,47 @@ +--- +title: "Comb Filter" +slug: "comb" +group: "effects" +order: 113 +--- + + + +Send effect with feedback comb filter. Creates pitched resonance, metallic timbres, and Karplus-Strong plucked sounds. Tail persists after voice ends. + + + +Send amount to comb filter. + + + +Noise into a tuned comb creates plucked string sounds (Karplus-Strong). + + + + + +Resonant frequency. All voices share the same orbit comb. + + + + + + + +Feedback amount. Higher values create longer resonance. + + + + + + + +High-frequency damping. Higher values darken the sound over time. + + + + diff --git a/website/src/content/delay.md b/website/src/content/delay.md new file mode 100644 index 0000000..53108e7 --- /dev/null +++ b/website/src/content/delay.md @@ -0,0 +1,58 @@ +--- +title: "Delay" +slug: "delay" +group: "effects" +order: 203 +--- + + + +Stereo delay line with feedback (max 1 second at 48kHz, clamped to 0.95 feedback). + + + +Send level to the delay bus. + + + + + + + +Feedback amount (clamped to 0.95 max). Output is fed back into input. + + + + + + + +Delay time in seconds (max ~1s at 48kHz). + + + + + + + +
    +
  • standard — Clean digital. Precise repeats.
  • +
  • pingpong — Mono in, bounces L→R→L→R.
  • +
  • tape — Each repeat darker. Analog warmth.
  • +
  • multitap — 4 taps. Feedback 0=straight, 1=triplet, between=swing.
  • +
+ + + + + + + + + + + +
diff --git a/website/src/content/envelope.md b/website/src/content/envelope.md new file mode 100644 index 0000000..d4070ce --- /dev/null +++ b/website/src/content/envelope.md @@ -0,0 +1,56 @@ +--- +title: "Envelope" +slug: "envelope" +group: "synthesis" +order: 102 +--- + + + +The envelope parameters control the shape of the gain envelope over time. It uses a typical ADSR envelope with exponential curves: + +- **Attack**: Ramps from 0 to full amplitude. Uses (slow start, fast finish). +- **Decay**: Falls from full amplitude to the sustain level. Uses 1-(1-x)² (fast drop, slow finish). +- **Sustain**: Holds at a constant level while the note is held. +- **Release**: Falls from the sustain level to 0 when the note ends. Uses 1-(1-x)² (fast drop, slow finish). + + + +The duration (seconds) of the attack phase of the gain envelope. + + + + + + + + + +The duration (seconds) of the decay phase of the gain envelope. + + + + + + + + + +The sustain level (0-1) of the gain envelope. + + + + + + + + + +The duration (seconds) of the release phase of the gain envelope. + + + + diff --git a/website/src/content/flanger.md b/website/src/content/flanger.md new file mode 100644 index 0000000..c6fcd84 --- /dev/null +++ b/website/src/content/flanger.md @@ -0,0 +1,43 @@ +--- +title: "Flanger" +slug: "flanger" +group: "effects" +order: 201 +--- + + + +LFO-modulated delay (0.5-10ms) with feedback and linear interpolation. Output is 50% dry, 50% wet. + + + +Flanger LFO rate in Hz. Creates sweeping comb filter effect with short delay modulation. + + + + + + + + + +Flanger modulation depth (0-1). Controls delay time sweep range. + + + + + + + + + +Flanger feedback amount (0-0.95). + + + + + + diff --git a/website/src/content/frequency-modulation.md b/website/src/content/frequency-modulation.md new file mode 100644 index 0000000..294c30e --- /dev/null +++ b/website/src/content/frequency-modulation.md @@ -0,0 +1,83 @@ +--- +title: "Frequency Modulation" +slug: "frequency-modulation" +group: "synthesis" +order: 108 +--- + + + +Any source can be frequency modulated. Frequency modulation (FM) is a technique where the frequency of a carrier wave is varied by an audio signal. This creates complex timbres and can produce rich harmonics, from mellow timbres to harsh digital noise. + + + +The frequency modulation index. FM multiplies the gain of the modulator, thus controls the amount of FM applied. + + + + + + + + + +The harmonic ratio of the frequency modulation. fmh*freq defines the modulation frequency. As a rule of thumb, numbers close to simple ratios sound more harmonic. + + + + + + + + + + + +FM modulator waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). Different shapes create different harmonic spectra. + + + + + + + +Envelope amount of frequency envelope. + + + + + + + +The duration (seconds) of the fm envelope's attack phase. + + + + + + + +The duration (seconds) of the fm envelope's decay phase. + + + + + + + +The sustain level of the fm envelope. + + + + + + + +The duration (seconds) of the fm envelope's release phase. + + + + diff --git a/website/src/content/ftype.md b/website/src/content/ftype.md new file mode 100644 index 0000000..ac00726 --- /dev/null +++ b/website/src/content/ftype.md @@ -0,0 +1,21 @@ +--- +title: "Filter Type" +slug: "ftype" +group: "effects" +order: 114 +--- + + + +Controls the steepness of all filters. Higher dB/octave values create sharper transitions between passed and attenuated frequencies. + + + +Filter slope steepness. Higher dB/octave values create sharper cutoffs. Applies to all filter types (lowpass, highpass, bandpass). + + + + diff --git a/website/src/content/gain.md b/website/src/content/gain.md new file mode 100644 index 0000000..bf5dd72 --- /dev/null +++ b/website/src/content/gain.md @@ -0,0 +1,45 @@ +--- +title: "Gain" +slug: "gain" +group: "synthesis" +order: 105 +--- + + + +The signal path is: oscillator → gain * velocity → filters → distortion → modulation → phaser/flanger → envelope * postgain → chorus → pan. + + + +Pre-filter gain multiplier. Applied before filters and distortion, combined with velocity as gain * velocity. + + + + + + + +Post-effects gain multiplier. Applied after phaser/flanger, combined with the envelope as envelope * postgain. + + + + + + + +Multiplied with gain before filters. Also passed as accent to Plaits engines. + + + + + + + +Stereo position using constant-power panning: left = cos(pan * π/2), right = sin(pan * π/2). 0 = left, 0.5 = center, 1 = right. + + + + diff --git a/website/src/content/highpass.md b/website/src/content/highpass.md new file mode 100644 index 0000000..b2642a3 --- /dev/null +++ b/website/src/content/highpass.md @@ -0,0 +1,69 @@ +--- +title: "Highpass Filter" +slug: "highpass" +group: "effects" +order: 111 +--- + + + +A highpass filter attenuates frequencies below the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency. + + + +Cutoff frequency in Hz. Frequencies below this are attenuated. + + + + + + + +Resonance (0-1). Boosts frequencies near the cutoff. + + + + + + + +Envelope amount. Positive values sweep the cutoff up, negative values sweep down. + + + + + + + +Envelope attack time in seconds. + + + + + + + +Envelope decay time in seconds. + + + + + + + +Envelope sustain level (0-1). + + + + + + + +Envelope release time in seconds. + + + + diff --git a/website/src/content/io.md b/website/src/content/io.md new file mode 100644 index 0000000..1085c7a --- /dev/null +++ b/website/src/content/io.md @@ -0,0 +1,25 @@ +--- +title: "Io" +slug: "io" +group: "sources" +order: 2 +--- + + + +This special source allows you to create a live audio input (microphone) source. Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally, envelopes are applied to the input signal too. + + + +Live audio input (microphone). Click the 'Enable Mic' button in the nav bar first. Effects chain applies normally. + + + + + + + + diff --git a/website/src/content/lofi.md b/website/src/content/lofi.md new file mode 100644 index 0000000..1b6afa9 --- /dev/null +++ b/website/src/content/lofi.md @@ -0,0 +1,61 @@ +--- +title: "Lo-Fi" +slug: "lofi" +group: "effects" +order: 205 +--- + + + +Sample rate reduction, bit crushing, and waveshaping distortion. + + + +Sample rate reduction. Holds each sample for n samples, creating stair-stepping and aliasing artifacts. + + + + + + + +Bit depth reduction. Quantizes amplitude to 2^(bits-1) levels, creating stepping distortion. + + + + + + + +Sine-based wavefold (Serge-style). At 1, near-passthrough. At 2, one fold per peak. At 4, two folds. + + + + + + + +Wrap distortion. Signal wraps around creating harsh digital artifacts. + + + + + + + +Soft-clipping waveshaper using (1+k)*x / (1+k*|x|) where k = e^amount - 1. Higher values add harmonic saturation. + + + + + + + +Output gain applied after distortion to compensate for increased level. + + + + diff --git a/website/src/content/lowpass.md b/website/src/content/lowpass.md new file mode 100644 index 0000000..bf22702 --- /dev/null +++ b/website/src/content/lowpass.md @@ -0,0 +1,69 @@ +--- +title: "Lowpass Filter" +slug: "lowpass" +group: "effects" +order: 110 +--- + + + +A lowpass filter attenuates frequencies above the cutoff. Each filter has its own ADSR envelope that modulates the cutoff frequency. + + + +Cutoff frequency in Hz. Frequencies above this are attenuated. + + + + + + + +Resonance (0-1). Boosts frequencies near the cutoff. + + + + + + + +Envelope amount. Positive values sweep the cutoff up, negative values sweep down. + + + + + + + +Envelope attack time in seconds. + + + + + + + +Envelope decay time in seconds. + + + + + + + +Envelope sustain level (0-1). + + + + + + + +Envelope release time in seconds. + + + + diff --git a/website/src/content/oscillator.md b/website/src/content/oscillator.md new file mode 100644 index 0000000..f12d933 --- /dev/null +++ b/website/src/content/oscillator.md @@ -0,0 +1,63 @@ +--- +title: "Oscillator" +slug: "oscillator" +group: "synthesis" +order: 104 +--- + + + +These parameters are dedicated to alter the nominal behavior of each oscillator. Some parameters are specific to certain oscillators, most others can be used with all oscillators. + + + +The pulse width (between 0 and 1) of the pulse oscillator. The default is 0.5 (square wave). Only has an effect when used with /sound/pulse or /sound/pulze. + + + + + + + +Stereo unison. Adds 6 detuned voices (7 total) with stereo panning. Works with sine, tri, saw, zaw, pulse, pulze. + + + + + +Inspired by the M8 Tracker's WavSynth, these parameters transform the oscillator phase to create new timbres from basic waveforms. They work with all basic oscillators (sine, tri, saw, zaw, pulse, pulze). + + + +Phase quantization steps. Creates stair-step waveforms similar to 8-bit sound chips. Set to 0 to disable, or 2-256 for increasing resolution. Lower values produce more lo-fi, chiptune-like sounds. + + + + + + + +Phase multiplier that wraps the waveform multiple times per cycle. Creates hard-sync-like harmonic effects. A value of 2 doubles the frequency content, 4 quadruples it, etc. + + + + + + + +Phase asymmetry using a power curve. Positive values compress the early phase and expand the late phase. Negative values do the opposite. Creates timbral variations without changing pitch. + + + + + + + +Reflects the phase at the specified position. At 0.5, creates symmetric waveforms (a saw becomes triangle-like). Values closer to 0 or 1 create increasingly asymmetric reflections. + + + + diff --git a/website/src/content/phaser.md b/website/src/content/phaser.md new file mode 100644 index 0000000..703b3a3 --- /dev/null +++ b/website/src/content/phaser.md @@ -0,0 +1,53 @@ +--- +title: "Phaser" +slug: "phaser" +group: "effects" +order: 200 +--- + + + +Two cascaded notch filters (offset by 282Hz) with LFO-modulated center frequency. + + + +Phaser LFO rate in Hz. Creates sweeping notch filter effect. + + + + + + + + + +Phaser effect intensity (0-1). Controls resonance and wet/dry mix. + + + + + + + + + +Phaser frequency sweep range in Hz. Default is 2000 (±2000Hz sweep). + + + + + + + + + +Phaser center frequency in Hz. Default is 1000Hz. + + + + + + diff --git a/website/src/content/pitch-env.md b/website/src/content/pitch-env.md new file mode 100644 index 0000000..de5b22e --- /dev/null +++ b/website/src/content/pitch-env.md @@ -0,0 +1,49 @@ +--- +title: "Pitch Env" +slug: "pitch-env" +group: "synthesis" +order: 106 +--- + + + +An ADSR envelope applied to pitch. The envelope runs with gate always on (no release phase during note). The frequency is multiplied by 2^(env * penv / 12). When psus = 1, the envelope value is offset by -1 so sustained notes return to base pitch. + + + +Pitch envelope depth in semitones. Positive values sweep up, negative values sweep down. + + + + + + + +Attack time. Duration to reach peak pitch offset. + + + + + + + +Decay time. Duration to fall from peak to sustain level. + + + + + + + +Sustain level. At 1.0, the envelope returns to base pitch after decay. + + + + + +Release time. Not typically audible since pitch envelope gate stays on. + + diff --git a/website/src/content/pitch.md b/website/src/content/pitch.md new file mode 100644 index 0000000..bba5bd3 --- /dev/null +++ b/website/src/content/pitch.md @@ -0,0 +1,65 @@ +--- +title: "Pitch" +slug: "pitch" +group: "synthesis" +order: 100 +--- + + + +Pitch control for all sources, including audio samples. + + + +The frequency of the sound. Has no effect on noise. + + + + + + + + + + + +The note (midi number) that should be played. +If both note and freq is set, freq wins. + + + + + + + + + +Multiplies with the source frequency or buffer playback speed. + + + + + + + + + +Shifts the pitch by the given amount in cents. 100 cents = 1 semitone. + + + + + + + + + +Creates a pitch slide when changing the frequency of an active voice. +Only has an effect when used with voice. + + + + diff --git a/website/src/content/plaits.md b/website/src/content/plaits.md new file mode 100644 index 0000000..c9617fa --- /dev/null +++ b/website/src/content/plaits.md @@ -0,0 +1,175 @@ +--- +title: "Complex" +slug: "plaits" +group: "sources" +order: 1 +--- + + + +Complex oscillator engines based on Mutable Instruments Plaits. All engines share three parameters (0 to 1): + +- **harmonics** — harmonic content, structure, detuning, etc. +- **timbre** — brightness, tonal color, etc. +- **morph** — smooth transitions between variations, etc. + +Each engine interprets these differently. + + + +Modal resonator (physical modeling). Simulates struck/plucked resonant bodies. harmonics: structure, timbre: brightness, morph: damping/decay. + + + + + + + + + + + +Virtual analog. Classic waveforms with sync and crossfading. harmonics: detuning, timbre: variable square, morph: variable saw. + + + + + + + + + + + +Waveshaping oscillator. Asymmetric triangle through waveshaper and wavefolder. harmonics: waveshaper shape, timbre: fold amount, morph: waveform asymmetry. + + + + + + + + + + + +Two-operator FM synthesis. harmonics: frequency ratio, timbre: modulation index, morph: feedback. + + + + + + + + + + + +Granular formant oscillator. Simulates formants through windowed sines. harmonics: formant ratio, timbre: formant frequency, morph: formant width. + + + + + + + + + + + +Harmonic oscillator. Additive mixture of sine harmonics. harmonics: number of bumps, timbre: prominent harmonic index, morph: bump shape. + + + + + + + + + + + +Wavetable oscillator. Four banks of 8x8 waveforms. harmonics: bank selection, timbre: row index, morph: column index. + + + + + + + + + + + +Four-note chord engine. Virtual analog or wavetable chords. harmonics: chord type, timbre: inversion/transposition, morph: waveform. + + + + + + + + + + + +Granular cloud of 8 enveloped sawtooth oscillators. harmonics: pitch randomization, timbre: grain density, morph: grain duration/overlap. + + + + + + + + + + + +Filtered noise. Clocked noise through multimode filter. harmonics: filter type (LP/BP/HP), timbre: clock frequency, morph: filter resonance. + + + + + + + + + + + +Analog bass drum. 808-style kick. harmonics: punch, timbre: tone, morph: decay. + + + + + + + + + + + +Analog snare drum. harmonics: tone/noise balance, timbre: drum mode balance, morph: decay. + + + + + + + + + + + +Analog hihat. 808-style metallic hihat. harmonics: metallic tone, timbre: high-pass filter, morph: decay. + + + + + + + + diff --git a/website/src/content/reverb.md b/website/src/content/reverb.md new file mode 100644 index 0000000..54f6758 --- /dev/null +++ b/website/src/content/reverb.md @@ -0,0 +1,53 @@ +--- +title: "Reverb" +slug: "reverb" +group: "effects" +order: 204 +--- + + + +Dattorro plate reverb with 4 input diffusers and a cross-fed stereo tank. + + + +Send level to the reverb bus. + + + + + + + +Tank feedback amount (clamped to 0.99 max). Controls tail length. + + + + + + + +One-pole lowpass in the tank feedback path. Higher values darken the tail. + + + + + + + +Delay before the diffusers (0-1 of max ~100ms). Creates space before reverb onset. + + + + + + + +Allpass coefficients in both input and tank diffusers. Higher values smear transients. + + + + diff --git a/website/src/content/rm.md b/website/src/content/rm.md new file mode 100644 index 0000000..4532d30 --- /dev/null +++ b/website/src/content/rm.md @@ -0,0 +1,37 @@ +--- +title: "Ring Modulation" +slug: "rm" +group: "synthesis" +order: 110 +--- + + + +Ring modulation is a crossfade between dry signal and full multiplication: signal *= (1.0 - depth) + modulator * depth. Unlike AM, ring modulation at full depth removes the carrier entirely, leaving only sum and difference frequencies at carrier ± modulator. + + + +Ring modulation oscillator frequency in Hz. When set above 0, an LFO multiplies the signal. + + + + + + + +Modulation depth (0-1). At 0, the signal is unchanged. At 1, full ring modulation with no dry signal. + + + + + + + +Ring modulation LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). + + + + diff --git a/website/src/content/sample.md b/website/src/content/sample.md new file mode 100644 index 0000000..6b4b21a --- /dev/null +++ b/website/src/content/sample.md @@ -0,0 +1,51 @@ +--- +title: "Sample" +slug: "sample" +group: "synthesis" +order: 111 +--- + + + +Doux can play back audio samples organized in folders. Point to a samples directory using the --samples flag. Each subfolder becomes a sample bank accessible via /s/folder_name. Use /n/ to index into a folder. + + + +Sample index within the folder. If the index exceeds the number of samples, it wraps around using modulo. Samples in a folder are indexed starting from 0. + + + + + + + + + +Sample start position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples. + + + + + + + + + +Sample end position (0-1). 0 = beginning, 0.5 = middle, 1 = end. Only works with samples. + + + + + + + + + +Choke group. Voices with the same cut value silence each other. Use for hi-hats where open should be cut by closed. + + + + diff --git a/website/src/content/timing.md b/website/src/content/timing.md new file mode 100644 index 0000000..f61305d --- /dev/null +++ b/website/src/content/timing.md @@ -0,0 +1,37 @@ +--- +title: "Timing" +slug: "timing" +group: "synthesis" +order: 101 +--- + + + +The engine clock starts at 0 and advances with each sample. Events with time are scheduled and fired when the clock reaches that value. The duration sets how long the gate stays open before triggering release. The repeat reschedules the event at regular intervals. + + + +The time at which the voice should start. Defaults to 0. + + + + + + + +The duration (seconds) of the gate phase. If not set, the voice will play indefinitely, until released explicitly. + + + + + + + +If set, the command is repeated within the given number of seconds. + + + + diff --git a/website/src/content/vibrato.md b/website/src/content/vibrato.md new file mode 100644 index 0000000..2ab96df --- /dev/null +++ b/website/src/content/vibrato.md @@ -0,0 +1,37 @@ +--- +title: "Vibrato" +slug: "vibrato" +group: "synthesis" +order: 107 +--- + + + +The pitch of every oscillator can be modulated by a vibrato effect. Vibrato is a technique where the pitch of a note is modulated slightly around a central pitch, creating a shimmering effect. + + + +Vibrato frequency (in hertz). + + + + + + + +Vibrato modulation depth (semitones). + + + + + + + +Vibrato LFO waveform shape. Options: `sine`, `tri`, `saw`, `square`, `sh` (sample-and-hold). + + + + diff --git a/website/src/content/voice.md b/website/src/content/voice.md new file mode 100644 index 0000000..52cb35c --- /dev/null +++ b/website/src/content/voice.md @@ -0,0 +1,29 @@ +--- +title: "Voice" +slug: "voice" +group: "synthesis" +order: 103 +--- + + + +Doux is a polyphonic synthesizer with up to 32 simultaneous voices. By default, each event allocates a new voice automatically. When a voice finishes (envelope reaches zero), it is freed and recycled. The voice parameter lets you take manual control over voice allocation, enabling parameter updates on active voices (e.g., pitch slides with glide) or retriggering with reset. + + + +The voice index to use. If set, voice allocation will be skipped and the selected voice will be used. If the voice is still active, the sent params will update the active voice. + + + + + + + +Only has an effect when used together with voice. If set to 1, the selected voice will be reset, even when it's still active. This will cause envelopes to retrigger for example. + + + + diff --git a/website/src/lib/components/CodeEditor.svelte b/website/src/lib/components/CodeEditor.svelte new file mode 100644 index 0000000..e0544ed --- /dev/null +++ b/website/src/lib/components/CodeEditor.svelte @@ -0,0 +1,111 @@ + + +
+
+ + +
+
+ {#if active} + + {:else} + + {/if} +
+
diff --git a/website/src/lib/components/CommandEntry.svelte b/website/src/lib/components/CommandEntry.svelte new file mode 100644 index 0000000..f4f0f94 --- /dev/null +++ b/website/src/lib/components/CommandEntry.svelte @@ -0,0 +1,131 @@ + + +
+ + {name} + {#if type && type !== "source"} + + {type} + {#if formatRange()} + {formatRange()}{#if unit} + {unit}{/if} + {:else if unit} + {unit} + {/if} + {#if defaultValue !== undefined} + ={defaultValue} + {/if} + {#if values} + {values.join(" | ")} + {/if} + + {/if} + +
+ {@render children()} +
+
+ + diff --git a/website/src/lib/components/Nav.svelte b/website/src/lib/components/Nav.svelte new file mode 100644 index 0000000..c578705 --- /dev/null +++ b/website/src/lib/components/Nav.svelte @@ -0,0 +1,114 @@ + + + + + + + diff --git a/website/src/lib/components/Scope.svelte b/website/src/lib/components/Scope.svelte new file mode 100644 index 0000000..6203c5e --- /dev/null +++ b/website/src/lib/components/Scope.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/website/src/lib/components/Sidebar.svelte b/website/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..ea2d827 --- /dev/null +++ b/website/src/lib/components/Sidebar.svelte @@ -0,0 +1,122 @@ + + + + + diff --git a/website/src/lib/doux.ts b/website/src/lib/doux.ts new file mode 100644 index 0000000..87a06ed --- /dev/null +++ b/website/src/lib/doux.ts @@ -0,0 +1,443 @@ +import type { DouxEvent, SoundInfo, ClockMessage, DouxOptions, PreparedMessage } from './types'; + +const soundMap = new Map(); +const loadedSounds = new Map(); +const loadingSounds = new Map>(); +let pcm_offset = 0; + +const sources = [ + 'triangle', 'tri', 'sine', 'sawtooth', 'saw', 'zawtooth', 'zaw', + 'pulse', 'square', 'pulze', 'zquare', 'white', 'pink', 'brown', + 'live', 'livein', 'mic' +]; + +function githubPath(base: string, subpath = ''): string { + if (!base.startsWith('github:')) { + throw new Error('expected "github:" at the start of pseudoUrl'); + } + let [, path] = base.split('github:'); + path = path.endsWith('/') ? path.slice(0, -1) : path; + if (path.split('/').length === 2) { + path += '/main'; + } + return `https://raw.githubusercontent.com/${path}/${subpath}`; +} + +async function fetchSampleMap(url: string): Promise<[Record, string] | undefined> { + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + const [, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + const [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + if (typeof fetch !== 'function') { + return; + } + const base = url.split('/').slice(0, -1).join('/'); + const json = await fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + return res.json(); + }) + .catch((error) => { + throw new Error(`error loading "${url}": ${error.message}`); + }); + return [json, json._base || base]; +} + +export async function douxsamples( + sampleMap: string | Record, + baseUrl?: string +): Promise { + if (typeof sampleMap === 'string') { + const result = await fetchSampleMap(sampleMap); + if (!result) return; + const [json, base] = result; + return douxsamples(json, base); + } + Object.entries(sampleMap).map(async ([key, urls]) => { + if (key !== '_base') { + urls = urls.map((url) => baseUrl + url); + soundMap.set(key, urls); + } + }); +} + +const BLOCK_SIZE = 128; +const CHANNELS = 2; +const CLOCK_SIZE = 16; + +// AudioWorklet processor code - runs in worklet context +const workletCode = ` +const BLOCK_SIZE = 128; +const CHANNELS = 2; +const CLOCK_SIZE = 16; + +let wasmExports = null; +let wasmMemory = null; +let output = null; +let input_buffer = null; +let event_input_ptr = 0; +let framebuffer = null; +let framebuffer_ptr = 0; +let frame_ptr = 0; +let frameIdx = 0; +let block = 0; + +class DouxProcessor extends AudioWorkletProcessor { + constructor(options) { + super(options); + this.active = true; + this.clock_active = options.processorOptions?.clock_active || false; + this.clockmsg = { + clock: true, + t0: 0, + t1: 0, + latency: (CLOCK_SIZE * BLOCK_SIZE) / sampleRate, + }; + this.port.onmessage = async (e) => { + const { wasm, evaluate, event_input, panic, writePcm } = e.data; + if (wasm) { + const { instance } = await WebAssembly.instantiate(wasm, {}); + wasmExports = instance.exports; + wasmMemory = wasmExports.memory; + wasmExports.doux_init(sampleRate); + event_input_ptr = wasmExports.get_event_input_pointer(); + output = new Float32Array( + wasmMemory.buffer, + wasmExports.get_output_pointer(), + BLOCK_SIZE * CHANNELS, + ); + input_buffer = new Float32Array( + wasmMemory.buffer, + wasmExports.get_input_buffer_pointer(), + BLOCK_SIZE * CHANNELS, + ); + framebuffer_ptr = wasmExports.get_framebuffer_pointer(); + frame_ptr = wasmExports.get_frame_pointer(); + const framebufferLen = Math.floor((sampleRate / 60) * CHANNELS) * 4; + framebuffer = new Float32Array(framebufferLen); + this.port.postMessage({ ready: true, sampleRate }); + } else if (writePcm) { + const { data, offset } = writePcm; + const pcm_ptr = wasmExports.get_sample_buffer_pointer(); + const pcm_len = wasmExports.get_sample_buffer_len(); + const pcm = new Float32Array(wasmMemory.buffer, pcm_ptr, pcm_len); + pcm.set(data, offset); + this.port.postMessage({ pcmWritten: offset }); + } else if (evaluate && event_input) { + new Uint8Array( + wasmMemory.buffer, + event_input_ptr, + event_input.length, + ).set(event_input); + wasmExports.evaluate(); + } else if (panic) { + wasmExports.panic(); + } + }; + } + + process(inputs, outputs, parameters) { + if (wasmExports && outputs[0][0]) { + if (input_buffer && inputs[0] && inputs[0][0]) { + for (let i = 0; i < inputs[0][0].length; i++) { + const offset = i * CHANNELS; + for (let c = 0; c < CHANNELS; c++) { + input_buffer[offset + c] = inputs[0][c]?.[i] ?? inputs[0][0][i]; + } + } + } + wasmExports.dsp(); + const out = outputs[0]; + for (let i = 0; i < out[0].length; i++) { + const offset = i * CHANNELS; + for (let c = 0; c < CHANNELS; c++) { + out[c][i] = output[offset + c]; + if (framebuffer) { + framebuffer[frameIdx * CHANNELS + c] = output[offset + c]; + } + } + frameIdx = (frameIdx + 1) % (framebuffer.length / CHANNELS); + } + + block++; + if (block % 8 === 0 && framebuffer) { + this.port.postMessage({ + framebuffer: framebuffer.slice(), + frame: frameIdx, + }); + } + + if (this.clock_active && block % CLOCK_SIZE === 0) { + this.clockmsg.t0 = this.clockmsg.t1; + this.clockmsg.t1 = wasmExports.get_time(); + this.port.postMessage(this.clockmsg); + } + } + return this.active; + } +} +registerProcessor("doux-processor", DouxProcessor); +`; + +export class Doux { + base: string; + BLOCK_SIZE = BLOCK_SIZE; + CHANNELS = CHANNELS; + ready: Promise; + sampleRate = 0; + frame: Int32Array = new Int32Array(1); + framebuffer: Float32Array = new Float32Array(0); + samplesReady: Promise | null = null; + + private initAudio: Promise; + private worklet: AudioWorkletNode | null = null; + private encoder: TextEncoder | null = null; + private micSource: MediaStreamAudioSourceNode | null = null; + private micStream: MediaStream | null = null; + private onTick?: (msg: ClockMessage) => void; + + constructor(options: DouxOptions = {}) { + this.base = options.base ?? '/'; + this.onTick = options.onTick; + this.initAudio = new Promise((resolve) => { + if (typeof document === 'undefined') return; + document.addEventListener('click', async function init() { + const ac = new AudioContext(); + await ac.resume(); + resolve(ac); + document.removeEventListener('click', init); + }); + }); + this.ready = this.runWorklet(); + } + + private async initWorklet(): Promise { + const ac = await this.initAudio; + const blob = new Blob([workletCode], { type: 'application/javascript' }); + const dataURL = URL.createObjectURL(blob); + await ac.audioWorklet.addModule(dataURL); + const worklet = new AudioWorkletNode(ac, 'doux-processor', { + outputChannelCount: [CHANNELS], + processorOptions: { clock_active: !!this.onTick } + }); + worklet.connect(ac.destination); + const res = await fetch(`${this.base}doux.wasm?t=${Date.now()}`); + const wasm = await res.arrayBuffer(); + return new Promise((resolve) => { + worklet.port.onmessage = async (e) => { + if (e.data.ready) { + this.sampleRate = e.data.sampleRate; + this.frame = new Int32Array(1); + this.frame[0] = 0; + const framebufferLen = Math.floor((this.sampleRate / 60) * CHANNELS) * 4; + this.framebuffer = new Float32Array(framebufferLen); + this.samplesReady = douxsamples('https://samples.raphaelforment.fr'); + resolve(worklet); + } else if (e.data.clock) { + this.onTick?.(e.data); + } else if (e.data.framebuffer) { + this.framebuffer.set(e.data.framebuffer); + this.frame[0] = e.data.frame; + } + }; + worklet.port.postMessage({ wasm }); + }); + } + + private async runWorklet(): Promise { + const ac = await this.initAudio; + if (ac.state !== 'running') await ac.resume(); + if (this.worklet) return; + this.worklet = await this.initWorklet(); + } + + parsePath(path: string): DouxEvent { + const chunks = path + .trim() + .split('\n') + .map((line) => line.split('//')[0]) + .join('') + .split('/') + .filter(Boolean); + const pairs: [string, string | undefined][] = []; + for (let i = 0; i < chunks.length; i += 2) { + pairs.push([chunks[i].trim(), chunks[i + 1]?.trim()]); + } + return Object.fromEntries(pairs); + } + + private encodeEvent(input: string | DouxEvent): Uint8Array { + if (!this.encoder) this.encoder = new TextEncoder(); + const str = + typeof input === 'string' + ? input + : Object.entries(input) + .map(([k, v]) => `${k}/${v}`) + .join('/'); + return this.encoder.encode(str + '\0'); + } + + async evaluate(input: DouxEvent): Promise { + const msg = await this.prepare(input); + return this.send(msg); + } + + async hush(): Promise { + await this.panic(); + const ac = await this.initAudio; + ac.suspend(); + } + + async resume(): Promise { + const ac = await this.initAudio; + if (ac.state !== 'running') await ac.resume(); + } + + async panic(): Promise { + await this.ready; + this.worklet?.port.postMessage({ panic: true }); + } + + async enableMic(): Promise { + await this.ready; + const ac = await this.initAudio; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const source = ac.createMediaStreamSource(stream); + if (this.worklet) source.connect(this.worklet); + this.micSource = source; + this.micStream = stream; + } + + disableMic(): void { + if (this.micSource) { + this.micSource.disconnect(); + this.micSource = null; + } + if (this.micStream) { + this.micStream.getTracks().forEach((t) => t.stop()); + this.micStream = null; + } + } + + async prepare(event: DouxEvent): Promise { + await this.ready; + if (this.samplesReady) await this.samplesReady; + await this.maybeLoadFile(event); + const encoded = this.encodeEvent(event); + return { + evaluate: true, + event_input: encoded + }; + } + + async send(msg: PreparedMessage): Promise { + await this.resume(); + this.worklet?.port.postMessage(msg); + } + + private async fetchSample(url: string): Promise { + const ac = await this.initAudio; + const encoded = encodeURI(url); + const buffer = await fetch(encoded) + .then((res) => res.arrayBuffer()) + .then((buf) => ac.decodeAudioData(buf)); + return buffer.getChannelData(0); + } + + private async loadSound(s: string, n = 0): Promise { + const soundKey = `${s}:${n}`; + + if (loadedSounds.has(soundKey)) { + return loadedSounds.get(soundKey)!; + } + + if (!loadingSounds.has(soundKey)) { + const urls = soundMap.get(s); + if (!urls) throw new Error(`sound ${s} not found in soundMap`); + const url = urls[n % urls.length]; + + const promise = this.fetchSample(url).then(async (data) => { + const offset = pcm_offset; + pcm_offset += data.length; + + await this.sendPcmData(data, offset); + + const info: SoundInfo = { + pcm_offset: offset, + frames: data.length, + channels: 1, + freq: 65.406 + }; + loadedSounds.set(soundKey, info); + return info; + }); + + loadingSounds.set(soundKey, promise); + } + + return loadingSounds.get(soundKey)!; + } + + private sendPcmData(data: Float32Array, offset: number): Promise { + return new Promise((resolve) => { + const handler = (e: MessageEvent) => { + if (e.data.pcmWritten === offset) { + this.worklet?.port.removeEventListener('message', handler); + resolve(); + } + }; + this.worklet?.port.addEventListener('message', handler); + this.worklet?.port.postMessage({ writePcm: { data, offset } }); + }); + } + + private async maybeLoadFile(event: DouxEvent): Promise { + const s = event.s || event.sound; + if (!s || typeof s !== 'string') return; + if (sources.includes(s)) return; + if (!soundMap.has(s)) return; + + const n = typeof event.n === 'string' ? parseInt(event.n) : event.n ?? 0; + const info = await this.loadSound(s, n); + event.file_pcm = info.pcm_offset; + event.file_frames = info.frames; + event.file_channels = info.channels; + event.file_freq = info.freq; + } + + async play(path: string): Promise { + await this.resume(); + if (this.samplesReady) await this.samplesReady; + const event = this.parsePath(path); + await this.maybeLoadFile(event); + const encoded = this.encodeEvent(event); + const msg = { + evaluate: true, + event_input: encoded + }; + this.worklet?.port.postMessage(msg); + } +} + +// Singleton instance +export const doux = new Doux(); + +// Load default samples +douxsamples('github:eddyflux/crate'); diff --git a/website/src/lib/navigation.json b/website/src/lib/navigation.json new file mode 100644 index 0000000..fe35386 --- /dev/null +++ b/website/src/lib/navigation.json @@ -0,0 +1,132 @@ +[ + { "name": "sine", "category": "basic", "group": "sources" }, + { "name": "tri", "category": "basic", "group": "sources" }, + { "name": "saw", "category": "basic", "group": "sources" }, + { "name": "zaw", "category": "basic", "group": "sources" }, + { "name": "pulse", "category": "basic", "group": "sources" }, + { "name": "pulze", "category": "basic", "group": "sources" }, + { "name": "white", "category": "basic", "group": "sources" }, + { "name": "pink", "category": "basic", "group": "sources" }, + { "name": "brown", "category": "basic", "group": "sources" }, + { "name": "modal", "category": "plaits", "group": "sources" }, + { "name": "va", "category": "plaits", "group": "sources" }, + { "name": "ws", "category": "plaits", "group": "sources" }, + { "name": "fm2", "category": "plaits", "group": "sources" }, + { "name": "grain", "category": "plaits", "group": "sources" }, + { "name": "additive", "category": "plaits", "group": "sources" }, + { "name": "wavetable", "category": "plaits", "group": "sources" }, + { "name": "chord", "category": "plaits", "group": "sources" }, + { "name": "swarm", "category": "plaits", "group": "sources" }, + { "name": "pnoise", "category": "plaits", "group": "sources" }, + { "name": "particle", "category": "plaits", "group": "sources" }, + { "name": "string", "category": "plaits", "group": "sources" }, + { "name": "speech", "category": "plaits", "group": "sources" }, + { "name": "kick", "category": "plaits", "group": "sources" }, + { "name": "snare", "category": "plaits", "group": "sources" }, + { "name": "hihat", "category": "plaits", "group": "sources" }, + { "name": "live", "category": "io", "group": "sources" }, + { "name": "freq", "category": "pitch", "group": "synthesis" }, + { "name": "note", "category": "pitch", "group": "synthesis" }, + { "name": "speed", "category": "pitch", "group": "synthesis" }, + { "name": "detune", "category": "pitch", "group": "synthesis" }, + { "name": "glide", "category": "pitch", "group": "synthesis" }, + { "name": "time", "category": "timing", "group": "synthesis" }, + { "name": "duration", "category": "timing", "group": "synthesis" }, + { "name": "repeat", "category": "timing", "group": "synthesis" }, + { "name": "attack", "category": "envelope", "group": "synthesis" }, + { "name": "decay", "category": "envelope", "group": "synthesis" }, + { "name": "sustain", "category": "envelope", "group": "synthesis" }, + { "name": "release", "category": "envelope", "group": "synthesis" }, + { "name": "voice", "category": "voice", "group": "synthesis" }, + { "name": "reset", "category": "voice", "group": "synthesis" }, + { "name": "pw", "category": "oscillator", "group": "synthesis" }, + { "name": "spread", "category": "oscillator", "group": "synthesis" }, + { "name": "size", "category": "oscillator", "group": "synthesis" }, + { "name": "mult", "category": "oscillator", "group": "synthesis" }, + { "name": "warp", "category": "oscillator", "group": "synthesis" }, + { "name": "mirror", "category": "oscillator", "group": "synthesis" }, + { "name": "timbre", "category": "plaits", "group": "synthesis" }, + { "name": "harmonics", "category": "plaits", "group": "synthesis" }, + { "name": "morph", "category": "plaits", "group": "synthesis" }, + { "name": "gain", "category": "gain", "group": "synthesis" }, + { "name": "postgain", "category": "gain", "group": "synthesis" }, + { "name": "velocity", "category": "gain", "group": "synthesis" }, + { "name": "pan", "category": "gain", "group": "synthesis" }, + { "name": "penv", "category": "pitch-env", "group": "synthesis" }, + { "name": "patt", "category": "pitch-env", "group": "synthesis" }, + { "name": "pdec", "category": "pitch-env", "group": "synthesis" }, + { "name": "psus", "category": "pitch-env", "group": "synthesis" }, + { "name": "prel", "category": "pitch-env", "group": "synthesis" }, + { "name": "vib", "category": "vibrato", "group": "synthesis" }, + { "name": "vibmod", "category": "vibrato", "group": "synthesis" }, + { "name": "vibshape", "category": "vibrato", "group": "synthesis" }, + { "name": "fmh", "category": "fm", "group": "synthesis" }, + { "name": "fm", "category": "fm", "group": "synthesis" }, + { "name": "fmshape", "category": "fm", "group": "synthesis" }, + { "name": "fmenv", "category": "fm", "group": "synthesis" }, + { "name": "fma", "category": "fm", "group": "synthesis" }, + { "name": "fmd", "category": "fm", "group": "synthesis" }, + { "name": "fms", "category": "fm", "group": "synthesis" }, + { "name": "fmr", "category": "fm", "group": "synthesis" }, + { "name": "am", "category": "am", "group": "synthesis" }, + { "name": "amdepth", "category": "am", "group": "synthesis" }, + { "name": "amshape", "category": "am", "group": "synthesis" }, + { "name": "rm", "category": "rm", "group": "synthesis" }, + { "name": "rmdepth", "category": "rm", "group": "synthesis" }, + { "name": "rmshape", "category": "rm", "group": "synthesis" }, + { "name": "n", "category": "sample", "group": "synthesis" }, + { "name": "begin", "category": "sample", "group": "synthesis" }, + { "name": "end", "category": "sample", "group": "synthesis" }, + { "name": "cut", "category": "sample", "group": "synthesis" }, + { "name": "lpf", "category": "lowpass", "group": "effects" }, + { "name": "lpq", "category": "lowpass", "group": "effects" }, + { "name": "lpe", "category": "lowpass", "group": "effects" }, + { "name": "lpa", "category": "lowpass", "group": "effects" }, + { "name": "lpd", "category": "lowpass", "group": "effects" }, + { "name": "lps", "category": "lowpass", "group": "effects" }, + { "name": "lpr", "category": "lowpass", "group": "effects" }, + { "name": "hpf", "category": "highpass", "group": "effects" }, + { "name": "hpq", "category": "highpass", "group": "effects" }, + { "name": "hpe", "category": "highpass", "group": "effects" }, + { "name": "hpa", "category": "highpass", "group": "effects" }, + { "name": "hpd", "category": "highpass", "group": "effects" }, + { "name": "hps", "category": "highpass", "group": "effects" }, + { "name": "hpr", "category": "highpass", "group": "effects" }, + { "name": "bpf", "category": "bandpass", "group": "effects" }, + { "name": "bpq", "category": "bandpass", "group": "effects" }, + { "name": "bpe", "category": "bandpass", "group": "effects" }, + { "name": "bpa", "category": "bandpass", "group": "effects" }, + { "name": "bpd", "category": "bandpass", "group": "effects" }, + { "name": "bps", "category": "bandpass", "group": "effects" }, + { "name": "bpr", "category": "bandpass", "group": "effects" }, + { "name": "comb", "category": "comb-filter", "group": "effects" }, + { "name": "combfreq", "category": "comb-filter", "group": "effects" }, + { "name": "combfeedback", "category": "comb-filter", "group": "effects" }, + { "name": "combdamp", "category": "comb-filter", "group": "effects" }, + { "name": "ftype", "category": "ftype", "group": "effects" }, + { "name": "phaser", "category": "phaser", "group": "effects" }, + { "name": "phaserdepth", "category": "phaser", "group": "effects" }, + { "name": "phasersweep", "category": "phaser", "group": "effects" }, + { "name": "phasercenter", "category": "phaser", "group": "effects" }, + { "name": "flanger", "category": "flanger", "group": "effects" }, + { "name": "flangerdepth", "category": "flanger", "group": "effects" }, + { "name": "flangerfeedback", "category": "flanger", "group": "effects" }, + { "name": "chorus", "category": "chorus", "group": "effects" }, + { "name": "chorusdepth", "category": "chorus", "group": "effects" }, + { "name": "chorusdelay", "category": "chorus", "group": "effects" }, + { "name": "delay", "category": "delay", "group": "effects" }, + { "name": "delayfeedback", "category": "delay", "group": "effects" }, + { "name": "delaytime", "category": "delay", "group": "effects" }, + { "name": "delaytype", "category": "delay", "group": "effects" }, + { "name": "verb", "category": "reverb", "group": "effects" }, + { "name": "verbdecay", "category": "reverb", "group": "effects" }, + { "name": "verbdamp", "category": "reverb", "group": "effects" }, + { "name": "verbpredelay", "category": "reverb", "group": "effects" }, + { "name": "verbdiff", "category": "reverb", "group": "effects" }, + { "name": "coarse", "category": "lofi", "group": "effects" }, + { "name": "crush", "category": "lofi", "group": "effects" }, + { "name": "fold", "category": "lofi", "group": "effects" }, + { "name": "wrap", "category": "lofi", "group": "effects" }, + { "name": "distort", "category": "lofi", "group": "effects" }, + { "name": "distortvol", "category": "lofi", "group": "effects" } +] diff --git a/website/src/lib/scope.ts b/website/src/lib/scope.ts new file mode 100644 index 0000000..4ee603c --- /dev/null +++ b/website/src/lib/scope.ts @@ -0,0 +1,99 @@ +import { doux } from './doux'; + +let ctx: CanvasRenderingContext2D | null = null; +let raf: number | null = null; + +const lerp = (v: number, min: number, max: number) => v * (max - min) + min; +const invLerp = (v: number, min: number, max: number) => (v - min) / (max - min); +const remap = (v: number, vmin: number, vmax: number, omin: number, omax: number) => + lerp(invLerp(v, vmin, vmax), omin, omax); + +function drawBuffer( + ctx: CanvasRenderingContext2D, + samples: Float32Array, + channels: number, + channel: number, + ampMin: number, + ampMax: number +) { + const lineWidth = 2; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = 'black'; + const perChannel = samples.length / channels / 2; + const pingbuffer = doux.frame[0] > samples.length / 2; + const s0 = pingbuffer ? 0 : perChannel; + const s1 = pingbuffer ? perChannel : perChannel * 2; + const px0 = ctx.lineWidth; + const px1 = ctx.canvas.width - ctx.lineWidth; + const py0 = ctx.lineWidth; + const py1 = ctx.canvas.height - ctx.lineWidth; + ctx.beginPath(); + for (let px = 1; px <= ctx.canvas.width; px++) { + const si = remap(px, px0, px1, s0, s1); + const idx = Math.floor(si) * channels + channel; + const amp = samples[idx]; + if (amp >= 1) ctx.strokeStyle = 'red'; + const py = remap(amp, ampMin, ampMax, py1, py0); + px === 1 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.stroke(); +} + +function drawScope() { + if (!ctx) return; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + for (let c = 0; c < doux.CHANNELS; c++) { + drawBuffer(ctx, doux.framebuffer, doux.CHANNELS, c, -1, 1); + } + raf = requestAnimationFrame(drawScope); +} + +export function initScope(canvas: HTMLCanvasElement) { + function resize() { + canvas.width = canvas.clientWidth * devicePixelRatio; + canvas.height = canvas.clientHeight * devicePixelRatio; + } + resize(); + ctx = canvas.getContext('2d'); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); +} + +export function startScope() { + if (!raf && ctx) { + drawScope(); + } +} + +export function stopScope() { + if (raf) { + cancelAnimationFrame(raf); + raf = null; + } + if (ctx) { + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } +} + +let activeResetCallback: (() => void) | null = null; + +export function registerActiveEditor(resetCallback: () => void) { + if (activeResetCallback && activeResetCallback !== resetCallback) { + activeResetCallback(); + } + activeResetCallback = resetCallback; +} + +export function unregisterActiveEditor(resetCallback: () => void) { + if (activeResetCallback === resetCallback) { + activeResetCallback = null; + } +} + +export function resetActiveEditor() { + if (activeResetCallback) { + activeResetCallback(); + activeResetCallback = null; + } +} diff --git a/website/src/lib/types.ts b/website/src/lib/types.ts new file mode 100644 index 0000000..dd218a1 --- /dev/null +++ b/website/src/lib/types.ts @@ -0,0 +1,37 @@ +export interface DouxEvent { + doux?: string; + s?: string; + sound?: string; + n?: string | number; + freq?: number; + wave?: string; + file_pcm?: number; + file_frames?: number; + file_channels?: number; + file_freq?: number; + [key: string]: string | number | undefined; +} + +export interface SoundInfo { + pcm_offset: number; + frames: number; + channels: number; + freq: number; +} + +export interface ClockMessage { + clock: boolean; + t0: number; + t1: number; + latency: number; +} + +export interface DouxOptions { + onTick?: (msg: ClockMessage) => void; + base?: string; +} + +export interface PreparedMessage { + evaluate: boolean; + event_input: Uint8Array; +} diff --git a/website/src/routes/+layout.js b/website/src/routes/+layout.js new file mode 100644 index 0000000..ba58d86 --- /dev/null +++ b/website/src/routes/+layout.js @@ -0,0 +1,2 @@ +export const prerender = true; +export const trailingSlash = 'always'; diff --git a/website/src/routes/+layout.server.ts b/website/src/routes/+layout.server.ts new file mode 100644 index 0000000..0b9b13a --- /dev/null +++ b/website/src/routes/+layout.server.ts @@ -0,0 +1,4 @@ +// Layout doesn't need to load content - it's loaded by +page.ts +export function load() { + return {}; +} diff --git a/website/src/routes/+layout.svelte b/website/src/routes/+layout.svelte new file mode 100644 index 0000000..2907acd --- /dev/null +++ b/website/src/routes/+layout.svelte @@ -0,0 +1,23 @@ + + + + +