commit c7d6c40683c624e119902bac02f64b6f10d6d536 Author: Emil Date: Sun Jun 21 22:07:05 2026 +0300 Initial release: 8-bit sound synthesizer with LLM/MCP integration Soundgen is a Rust workspace for generating 8-bit/chiptune sound effects, UI sounds, and ambient textures for game audio assets. It features JSON-first sound specs, an MCP server for LLM tool-use, an egui GUI editor, and a runtime library with NES-authentic DAC emulation. Features: - 6 voice types: pulse, triangle, noise, DPCM, wavetable, FM - Effects: ADSR envelope, frequency sweep, biquad filter, vibrato - 13 built-in presets (SFX/UI/ambient) as JSON data files - MCP server: list_presets, generate_sfx, render_sound tools - Pattern-based sequencer (JSON song format) - egui GUI: virtual keyboard, preset browser, channel editor, undo/redo - Runtime: NES nonlinear DAC + SoundBank for game embedding - 90 tests, 0 warnings Crates: - soundgen-core: synthesis engine (no I/O) - soundgen-fmt: SoundSpec JSON schema + PresetRegistry - soundgen-io: WAV writer + audio playback - soundgen-seq: sequencer (patterns, songs) - soundgen-cli: gen/render/render-song/list-presets - soundgen-mcp: MCP server for LLM integration - soundgen-gui: egui editor - soundgen-runtime: NES DAC + SoundBank diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f483b38 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ALSA dev + run: sudo apt-get update && sudo apt-get install -y libasound2-dev + + - uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build --workspace --verbose + + - name: Build GUI + run: cargo build -p soundgen-gui --verbose + + - name: Test + run: cargo test --workspace --verbose + + - name: Build examples + run: cargo build --workspace --examples --verbose + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + continue-on-error: true + + - name: Format check + run: cargo fmt --all -- --check + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43aad9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Rust build artifacts +/target/ +debug/ +release/ + +# WAV output (generated, not source) +*.wav +/sfx_output/ + +# User-generated sound specs +/sound.json + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.bak diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d1ba4a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# Soundgen + +8-bit sound synthesizer in Rust for game audio assets, with LLM integration via MCP. + +## Status + +Phases 1-4 complete. `PLAN.md` is the authoritative design doc. + +## Architecture (from PLAN.md) + +Cargo **workspace** with these crate boundaries: + +- `soundgen-core` — synthesis engine (generators, effects, mixer). No I/O. Renders to `Vec`. +- `soundgen-fmt` — `SoundSpec` JSON schema (serde) + `PresetRegistry` loading from `presets/`. +- `soundgen-io` — WAV writer (`hound`) and playback (subprocess fallback: `paplay`/`aplay`). +- `soundgen-seq` — sequencer: patterns, songs. +- `soundgen-cli` — `gen`, `render`, `render-song`, `list-presets` commands (`clap`). +- `soundgen-mcp` — MCP server exposing `list_presets`, `generate_sfx`, `render_sound` tools (JSON-RPC over stdio, no rmcp dependency). +- `soundgen-gui` — egui editor with virtual keyboard, preset browser, SFX editor, sequencer, undo/redo, file dialogs. +- `soundgen-runtime` — NES-authentic nonlinear DAC + `SoundBank` for game embedding. + +Presets are **JSON data files** in `presets/{sfx,ui,ambient}/`, not compiled code. + +## Conventions + +- **JSON-first**: every sound is a `SoundSpec` JSON object. LLMs generate JSON; CLI/MCP render to WAV. +- **No allocations in audio hot path**: generators use `&mut self`, `tick() -> f32`, no `Vec` per-sample. +- **Presets as data**: extend `presets/` with new `.json` files, no recompilation needed. +- **MIT license**. + +## Commands + +```bash +cargo test --workspace # run all tests (90 passing) +cargo run --bin soundgen -- list-presets +cargo run --bin soundgen -- gen --out +cargo run --bin soundgen -- render --out +cargo run --bin soundgen -- render-song --out +cargo run -p soundgen-mcp -- --presets-dir presets # MCP server on stdio +cargo run -p soundgen-gui # GUI editor +``` + +## Roadmap + +Phase 1 (MVP): core generators (pulse/triangle/noise) + envelope/sweep/mixer + WAV I/O + CLI + SFX/UI presets. +Phase 2: DPCM/wavetable/FM + sequencer + MCP server + ambient presets. +Phase 3: egui GUI + realtime playback. +Phase 4: NES-authentic DAC, runtime library, sound bank. + +See `PLAN.md` for full details, dependency versions, and the `SoundSpec` JSON format. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1a12e0d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4875 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a4b14f3d99c1255dcba8f45621ab1a2e7540a0009652d33989005a4d0bfc6b" + +[[package]] +name = "accesskit_consumer" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c17cca53c09fbd7288667b22a201274b9becaa27f0b91bf52a526db95de45e6" +dependencies = [ + "accesskit", +] + +[[package]] +name = "accesskit_macos" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3b6ae1eabbfbced10e840fd3fce8a93ae84f174b3e4ba892ab7bcb42e477a7" +dependencies = [ + "accesskit", + "accesskit_consumer", + "objc2 0.3.0-beta.3.patch-leaks.3", + "once_cell", +] + +[[package]] +name = "accesskit_unix" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f46c18d99ba61ad7123dd13eeb0c104436ab6af1df6a1cd8c11054ed394a08" +dependencies = [ + "accesskit", + "accesskit_consumer", + "async-channel", + "async-once-cell", + "atspi", + "futures-lite 1.13.0", + "once_cell", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcae27ec0974fc7c3b0b318783be89fd1b2e66dd702179fe600166a38ff4a0b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "once_cell", + "paste", + "static_assertions", + "windows 0.48.0", +] + +[[package]] +name = "accesskit_winit" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5284218aca17d9e150164428a0ebc7b955f70e3a9a78b4c20894513aabf98a67" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "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.13.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 = "android-activity" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "cesu8", + "jni 0.21.1", + "jni-sys 0.3.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.4.1", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6059f350ab6f593ea00727b334265c4dfc7fd442ee32d264794bd9bdc68e87ca" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92af95f966d2431f962bc632c2e68eda7777330158bf640c4af4249349b2cdf5" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c65e7d70f86d4c0e3b2d585d9bf3f979f0b19d635a336725a88d279f76b939" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite 1.13.0", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6495661273703e7a229356dcbe8c8f38223d697aacfaf0e13590a9ac9977bb52" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "block-sys" +version = "0.1.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa55741ee90902547802152aaf3f8e5248aab7e21468089560d4c8840561146" +dependencies = [ + "objc-sys 0.2.0-beta.2", +] + +[[package]] +name = "block-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +dependencies = [ + "objc-sys 0.3.5", +] + +[[package]] +name = "block2" +version = "0.2.0-alpha.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd9e63c1744f755c2f60332b88de39d341e5e86239014ad839bd71c106dec42" +dependencies = [ + "block-sys 0.1.0-beta.1", + "objc2-encode 2.0.0-pre.2", +] + +[[package]] +name = "block2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" +dependencies = [ + "block-sys 0.2.1", + "objc2 0.4.1", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "calloop" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling 3.11.0", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.0", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +dependencies = [ + "calloop 0.12.4", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[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 = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[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 = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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 = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[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 0.21.1", + "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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[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 = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +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 = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ecolor" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6b451ff1143f6de0f33fc7f1b68fecfd2c7de06e104de96c4514de3f5396f8" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6490ef800b2e41ee129b1f32f9ac15f713233fe3bc18e241a1afe1e4fb6811e0" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "winit", +] + +[[package]] +name = "egui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c97e70a2768de630f161bb5392cbd3874fcf72868f14df0e002e82e06cb798" +dependencies = [ + "accesskit", + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-file-dialog" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eeabfc7f7204e6bb45d3394d16c2611a858f742e910d9e2e0a31dae65779e4" +dependencies = [ + "directories", + "egui", + "serde", + "sysinfo", +] + +[[package]] +name = "egui-wgpu" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c7a7c707877c3362a321ebb4f32be811c0b91f7aebf345fb162405c0218b4c" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4e066af341bf92559f60dbdf2020b2a03c963415349af5f3f8d79ff7a4926" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle 0.6.2", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2bdc8b38cfa17cc712c4ae079e30c71c00cd4c2763c9e16dc7860a02769103" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emath" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6a21708405ea88f63d8309650b4d77431f4bc28fb9d8e6f77d3963b51249e6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "epaint" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0dcc0a0771e7500e94cd1cb797bd13c9f23b9409bdc3c824e2cbc562b7fa01" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "log", + "nohash-hasher", + "parking_lot", +] + +[[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 = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.4.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[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 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fcd4ae4e86d991ad1300b8f57166e5be0c95ef1f63f3f5b827f8a164548746" +dependencies = [ + "bitflags 2.13.0", + "cfg_aliases", + "cgl", + "core-foundation 0.9.4", + "dispatch", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "icrate", + "libloading 0.8.9", + "objc2 0.4.1", + "once_cell", + "raw-window-handle 0.5.2", + "wayland-sys", + "windows-sys 0.48.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebcdfba24f73b8412c5181e56f092b5eff16671c514ce896b258a0a64bd7735" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle 0.5.2", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77cc5623f5309ef433c3dd4ca1223195347fe62c413da8e2fdd0eb76db2d9bcd" +dependencies = [ + "gl_generator", + "windows-sys 0.48.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a165fd686c10dcc2d45380b35796e577eacfd43d4660ee741ec8ebe2201b3b4f" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.13.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "icrate" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" +dependencies = [ + "block2 0.3.0", + "dispatch", + "objc2 0.4.1", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[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 = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5637e166ea14be6063a3f8ba5ccb9a4159df7d8f6d61c02fc3d480b1f90dcfcb" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e536ae46fcab0876853bd4a632ede5df4b1c2527a58f6c5a4150fe86be858231" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.2.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b9834c1e95694a05a828b59f55fa2afec6288359cda67146126b3f90a55d7" + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.3.0-beta.3.patch-leaks.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e01640f9f2cb1220bbe80325e179e532cb3379ebcd1bf2279d703c19fe3a468" +dependencies = [ + "block2 0.2.0-alpha.6", + "objc-sys 0.2.0-beta.2", + "objc2-encode 2.0.0-pre.2", +] + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys 0.3.5", + "objc2-encode 3.0.0", +] + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys 0.3.5", + "objc2-encode 4.1.0", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode 4.1.0", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-encode" +version = "2.0.0-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abfcac41015b00a120608fdaa6938c44cb983fee294351cc4bac7638b4e50512" +dependencies = [ + "objc-sys 0.2.0-beta.2", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.4.1", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.13.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 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.18.1", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" +dependencies = [ + "bitflags 2.13.0", + "calloop 0.12.4", + "calloop-wayland-source 0.2.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.31.2", + "wayland-protocols-wlr 0.2.0", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.0", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.32.13", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr 0.3.12", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "soundgen-cli" +version = "0.1.0" +dependencies = [ + "clap", + "hound", + "serde_json", + "soundgen-core", + "soundgen-fmt", + "soundgen-io", + "soundgen-seq", +] + +[[package]] +name = "soundgen-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "soundgen-fmt" +version = "0.1.0" +dependencies = [ + "hound", + "serde", + "serde_json", + "soundgen-core", + "soundgen-io", + "soundgen-seq", +] + +[[package]] +name = "soundgen-gui" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "egui-file-dialog", + "serde_json", + "soundgen-core", + "soundgen-fmt", + "soundgen-io", + "soundgen-seq", +] + +[[package]] +name = "soundgen-io" +version = "0.1.0" +dependencies = [ + "cpal", + "hound", + "soundgen-core", + "soundgen-fmt", +] + +[[package]] +name = "soundgen-mcp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "soundgen-core", + "soundgen-fmt", + "soundgen-io", +] + +[[package]] +name = "soundgen-runtime" +version = "0.1.0" +dependencies = [ + "soundgen-core", + "soundgen-fmt", + "soundgen-io", +] + +[[package]] +name = "soundgen-seq" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "soundgen-core", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "windows 0.54.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.4.1", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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 = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "wgpu" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e37c7b9921b75dfd26dd973fdcbce36f13dfa6e2dc82aece584e0ed48c355c" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50819ab545b867d8a454d1d756b90cd5f15da1f2943334ca314af10583c9d39" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.0", + "cfg_aliases", + "codespan-reporting", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172e490a87295564f3fcc0f165798d87386f6231b04d4548bca458cbbfd63222" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.13.0", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1353d9a46bff7f955a680577f34c69122628cc2076e1d6f3a9be6ef00ae793ef" +dependencies = [ + "bitflags 2.13.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[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-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "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-implement" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ee588991b9e7e6c8338edf3333fbe4da35dc72092643958ebb43f0ab2c49c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "windows-interface" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6fb8df20c9bcaa8ad6ab513f7b40104840c8867d5751126e4df3b08388d0cc7" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[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.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 = "winit" +version = "0.29.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "bytemuck", + "calloop 0.12.4", + "cfg_aliases", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "icrate", + "js-sys", + "libc", + "log", + "memmap2", + "ndk", + "ndk-sys", + "objc2 0.4.1", + "once_cell", + "orbclient", + "percent-encoding", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "redox_syscall 0.3.5", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.18.1", + "smol_str", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.48.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "once_cell", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ebe3aba --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +members = [ + "crates/soundgen-core", + "crates/soundgen-fmt", + "crates/soundgen-io", + "crates/soundgen-seq", + "crates/soundgen-cli", + "crates/soundgen-mcp", + "crates/soundgen-gui", + "crates/soundgen-runtime", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/emil/soundgen" + +[workspace.dependencies] +soundgen-core = { path = "crates/soundgen-core" } +soundgen-fmt = { path = "crates/soundgen-fmt" } +soundgen-io = { path = "crates/soundgen-io" } +soundgen-seq = { path = "crates/soundgen-seq" } +soundgen-runtime = { path = "crates/soundgen-runtime" } + +hound = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +cpal = "0.15" +eframe = "0.28" +egui = "0.28" +egui-file-dialog = "0.6" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..37f16a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soundgen Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..db48d20 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,237 @@ +# Soundgen — 8-bit синтезатор на Rust + LLM-интеграция + +## Статус: Фазы 1-4 завершены + +| Параметр | Значение | +|---|---| +| Язык | Rust | +| Игра | Rust + Vulkan (custom engine) | +| Генераторы | Свои, с нуля | +| GUI | egui (фаза 3) | +| MCP | Да, через `rmcp` (официальный Rust MCP SDK) | +| Звуки | SFX, UI sounds, Ambient | +| Генерация | Build time → WAV-ассеты | +| Лицензия | MIT | + +## Архитектура Cargo workspace + +``` +soundgen/ +├── Cargo.toml # [workspace], shared deps +├── LICENSE # MIT +├── README.md +│ +├── crates/ +│ ├── soundgen-core/ # чистый движок синтеза, без I/O +│ │ └── src/ +│ │ ├── lib.rs # реэкспорты +│ │ ├── generator.rs # trait Generator, Voice, Param +│ │ ├── voice/ +│ │ │ ├── pulse.rs # PulseChannel (duty 12.5/25/50/75%) +│ │ │ ├── triangle.rs # TriangleChannel (32-step) +│ │ │ ├── noise.rs # NoiseChannel (LFSR white/periodic) +│ │ │ ├── dpcm.rs # (фаза 2) +│ │ │ ├── wavetable.rs # (фаза 2, GB wave-style) +│ │ │ └── fm.rs # (фаза 2, 4-op FM) +│ │ ├── effect/ +│ │ │ ├── envelope.rs # ADSR +│ │ │ ├── sweep.rs # freq sweep (linear/exponential) +│ │ │ ├── vibrato.rs # LFO (фаза 2) +│ │ │ └── filter.rs # biquad lowpass/highpass (свой) +│ │ ├── automation.rs # FrequencyAutomation, Arpeggiator +│ │ └── mixer.rs # Mixer: N голосов → стерео +│ │ +│ ├── soundgen-fmt/ # декларативный формат + пресеты +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── spec.rs # SoundSpec (serde JSON) +│ │ └── preset.rs # PresetRegistry, built-in presets +│ │ +│ ├── soundgen-io/ # I/O слой +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── wav.rs # hound: WAV writer (16/24-bit) +│ │ └── realtime.rs # cpal (фаза 3) +│ │ +│ ├── soundgen-seq/ # секвенсер (фаза 2) +│ │ └── src/ +│ │ ├── pattern.rs # Pattern, Row, Note +│ │ ├── song.rs # Song, Track +│ │ └── sequencer.rs # Sequencer +│ │ +│ ├── soundgen-cli/ # CLI binary +│ │ └── src/ +│ │ ├── main.rs +│ │ └── commands/ +│ │ ├── gen.rs # gen --preset jump --out ... +│ │ ├── render.rs # render song.json --out ... +│ │ └── list.rs # list-presets +│ │ +│ ├── soundgen-mcp/ # MCP server для LLM (фаза 2) +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── server.rs # rmcp ServerHandler impl +│ │ └── tools.rs # generate_sfx, list_presets, render +│ │ +│ └── soundgen-gui/ # egui GUI (фаза 3) +│ └── src/ +│ ├── main.rs +│ ├── keyboard.rs # виртуальная клавиатура +│ ├── channel_panel.rs # регуляторы каналов +│ └── preset_browser.rs # браузер пресетов +│ +├── presets/ # встроенная библиотека (JSON) +│ ├── sfx/ +│ │ ├── jump.json +│ │ ├── explosion.json +│ │ ├── coin.json +│ │ ├── laser.json +│ │ ├── hit.json +│ │ └── powerup.json +│ ├── ui/ +│ │ ├── click.json +│ │ ├── hover.json +│ │ ├── confirm.json +│ │ └── error.json +│ └── ambient/ # (фаза 2) +│ ├── wind.json +│ ├── rain.json +│ └── drone.json +│ +├── examples/ +│ ├── play_melody.rs # фаза 1: pulse+triangle+noise → WAV +│ ├── generate_sfx.rs # фаза 1: SFX из кода +│ └── render_song.rs # фаза 2: song.json → WAV +│ +└── tests/ + └── integration.rs # рендер WAV, проверка RMS/длительности +``` + +## Декларативный формат (JSON) — LLM-friendly + +Пример `presets/sfx/jump.json`: + +```json +{ + "name": "jump", + "duration": 0.3, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 800, "curve": "exponential" }, + "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, + "volume": 0.7 + } + ] +} +``` + +Пример `presets/sfx/explosion.json`: + +```json +{ + "name": "explosion", + "duration": 0.8, + "channels": [ + { + "type": "noise", + "mode": "white", + "filter": { "type": "lowpass", "cutoff": { "start": 2000, "end": 200, "curve": "exponential" } }, + "envelope": { "attack": 0.005, "decay": 0.7, "sustain": 0.0, "release": 0.095 }, + "volume": 0.9 + } + ] +} +``` + +LLM генерирует такой JSON → CLI/MCP рендерит → WAV-ассет готов. + +## MCP server — tools для LLM + +Сервер на `rmcp` (stdio transport), expose 3 tool'а: + +| Tool | Параметры | Возвращает | +|---|---|---| +| `list_presets` | `category?: "sfx"\|"ui"\|"ambient"` | JSON-список пресетов с описаниями | +| `generate_sfx` | `preset: string`, `params?: object`, `out_path: string` | Путь к WAV + метаданные (длительность, размер) | +| `render_sound` | `spec: object` (полный SoundSpec JSON), `out_path: string` | Путь к WAV + метаданные | + +Workflow LLM при разработке игры: + +1. LLM вызывает `list_presets` → видит доступные звуки +2. LLM вызывает `generate_sfx { preset: "jump", out_path: "assets/sfx/jump.wav" }` → WAV создан +3. Для кастомного звука: LLM пишет JSON-spec и вызывает `render_sound { spec: {...}, out_path: "assets/sfx/laser.wav" }` +4. LLM пишет Rust-код игры, загружающий WAV через `hound`/`rodio` + +## Зависимости + +```toml +# workspace +[workspace.dependencies] +hound = "0.5" # WAV I/O +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# soundgen-io (фаза 3) +cpal = "0.15" + +# soundgen-mcp (фаза 2) +rmcp = "1.7" +tokio = { version = "1", features = ["full"] } + +# soundgen-cli +clap = { version = "4", features = ["derive"] } + +# soundgen-gui (фаза 3) +eframe = "0.27" +egui = "0.27" +``` + +## Фазы разработки + +### Фаза 1 — Core + CLI + Пресеты (MVP) + +Цель: работающая библиотека, CLI, и набор готовых пресетов для SFX/UI. + +- **soundgen-core**: `Generator`/`Voice`/`Param` трейты, `PulseChannel`, `TriangleChannel`, `NoiseChannel`, `Envelope` (ADSR), `Sweep` (linear/exponential), `FrequencyAutomation`, `Mixer`, простые biquad filters (lowpass/highpass — свой код) +- **soundgen-fmt**: `SoundSpec` (serde), `PresetRegistry` (загрузка JSON из `presets/`) +- **soundgen-io**: `wav::write(path, samples, sr, bit_depth)` через `hound` +- **soundgen-cli**: `gen --preset [--param k=v]... --out `, `gen --from --out `, `list-presets [--category sfx|ui|ambient]` +- **Пресеты**: jump, explosion, coin, laser, hit, powerup (SFX); click, hover, confirm, error (UI) +- **Examples**: `play_melody`, `generate_sfx` +- **Тесты**: unit на duty cycle/частоту/envelope; интеграционный — рендер WAV, проверка длительности и RMS + +### Фаза 2 — Extended synthesis + Sequencer + MCP + +Цель: расширенный синтез, секвенсер для мелодий, MCP-сервер для LLM. + +- **soundgen-core**: `DpcmChannel`, `WavetableChannel`, `FmChannel`, `Vibrato` (LFO) +- **soundgen-seq**: `Pattern`, `Song`, `Sequencer` (tempo, rows, note triggers) +- **soundgen-mcp**: rmcp-сервер, tools: `list_presets`, `generate_sfx`, `render_sound` +- **Ambient пресеты**: wind (filtered noise + slow LFO), rain (noise + highpass), drone (low freq sustained) +- **CLI**: `render song.json --out music.wav` +- **Example**: `render_song` + +### Фаза 3 — GUI (egui) + +Цель: интерактивный редактор для человека. + +- egui app: виртуальная клавиатура (мышью/клавишами), панель каналов (duty, freq, envelope), браузер пресетов, pattern editor +- cpal realtime playback (ring buffer) +- Сохранение/загрузка проектов (JSON) + +### Фаза 4 — Advanced (опционально) + +- NES-authentic: нелинейный DAC, DPCM corruption, hardware-accurate mixing +- VST плагин через `nih-plug` +- Runtime library для прямой интеграции в Rust+Vulkan игру + +## Ключевые принципы + +- **Чистый core без I/O** — рендеринг в `Vec` в тестах без звуковой карты +- **Sample-accurate tick** — `Voice::tick() -> f32`, `Mixer` собирает буфер +- **Без allocations в hot path** — генераторы работают с `&mut self`, без `Vec` в audio thread +- **JSON-first для LLM** — каждый звук описывается JSON, LLM генерирует JSON естественно +- **Пресеты как data, не code** — JSON-файлы в `presets/`, расширяемые без перекомпиляции diff --git a/README.md b/README.md new file mode 100644 index 0000000..d703ba4 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Soundgen + +8-bit sound synthesizer in Rust for game audio assets, with LLM integration via MCP. + +## Features + +- **6 voice types**: pulse (NES duty cycles), triangle, noise (LFSR), DPCM samples, wavetable (Game Boy wave), FM (2-operator) +- **Effects**: ADSR envelope, frequency sweep (linear/exponential), biquad filter (lowpass/highpass), vibrato +- **JSON-first**: every sound is a `SoundSpec` JSON object — LLMs generate JSON, CLI/MCP render to WAV +- **Presets as data**: 13 built-in presets in `presets/{sfx,ui,ambient}/` — extend without recompilation +- **MCP server**: LLMs can call `list_presets`, `generate_sfx`, `render_sound` as tools +- **Sequencer**: pattern-based song playback (JSON format) +- **GUI**: egui editor with virtual keyboard, preset browser, channel controls, sequencer +- **Runtime library**: NES-authentic nonlinear DAC + SoundBank for game embedding +- **No allocations in audio hot path**: `tick() -> f32`, `&mut self` + +## Quick Start + +```bash +# List available presets +cargo run --bin soundgen -- list-presets + +# Generate a sound from a preset +cargo run --bin soundgen -- gen jump --out assets/jump.wav + +# Generate with parameter override +cargo run --bin soundgen -- gen explosion --out assets/explosion.wav --param volume=0.95 + +# Render from a custom JSON spec +cargo run --bin soundgen -- render presets/sfx/laser.json --out laser.wav + +# Render a song (sequencer) +cargo run --bin soundgen -- render-song song.json --out music.wav + +# Launch the GUI editor +cargo run -p soundgen-gui +``` + +## MCP Server (for LLM integration) + +Run the MCP server on stdio: + +```bash +cargo run -p soundgen-mcp -- --presets-dir presets +``` + +Configure in Claude Desktop / MCP client: + +```json +{ + "mcpServers": { + "soundgen": { + "command": "/path/to/soundgen-mcp", + "args": ["--presets-dir", "/path/to/presets"] + } + } +} +``` + +LLM workflow: +1. `list_presets` → see available sounds +2. `generate_sfx { preset: "jump", out_path: "assets/jump.wav" }` → WAV created +3. `render_sound { spec: {...}, out_path: "assets/custom.wav" }` → custom sound + +## SoundSpec JSON Format + +```json +{ + "name": "jump", + "duration": 0.3, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 800, "curve": "exponential" }, + "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, + "volume": 0.7 + } + ] +} +``` + +Channel types: `pulse`, `triangle`, `noise` + +## Song JSON Format (Sequencer) + +```json +{ + "bpm": 120, + "rows_per_beat": 4, + "tracks": [ + { "type": "pulse", "duty": 50, "volume": 0.4 } + ], + "patterns": [ + { "rows": [ { "notes": [{"frequency": 440}] }, {"notes": [null]} ] } + ], + "pattern_order": [0] +} +``` + +## Architecture + +Cargo workspace: + +| Crate | Purpose | +|---|---| +| `soundgen-core` | Synthesis engine (generators, effects, mixer). No I/O. | +| `soundgen-fmt` | `SoundSpec` JSON schema + `PresetRegistry` | +| `soundgen-io` | WAV writer (`hound`) + playback (subprocess) | +| `soundgen-seq` | Sequencer: patterns, songs | +| `soundgen-cli` | `gen`, `render`, `render-song`, `list-presets` | +| `soundgen-mcp` | MCP server for LLM tool-use | +| `soundgen-gui` | egui GUI editor | +| `soundgen-runtime` | NES-authentic DAC + `SoundBank` for game embedding | + +## Runtime Library (for game integration) + +```rust +use soundgen_runtime::SoundBank; + +// Load all presets at init time +let bank = SoundBank::load_dir(std::path::Path::new("presets"))?; + +// Play by name (zero-allocation, returns pre-rendered buffer) +let (samples, sample_rate) = bank.get("jump").unwrap(); + +// Pitch-shifted variant +let (pitched, sr) = bank.get_pitched("jump", 1.5)?; +``` + +## Built-in Presets + +**SFX**: jump, explosion, coin, laser, hit, powerup +**UI**: click, hover, confirm, error +**Ambient**: wind, rain, drone + +## License + +MIT diff --git a/crates/soundgen-cli/Cargo.toml b/crates/soundgen-cli/Cargo.toml new file mode 100644 index 0000000..6d035be --- /dev/null +++ b/crates/soundgen-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "soundgen-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "soundgen" +path = "src/main.rs" + +[dependencies] +soundgen-core.workspace = true +soundgen-fmt.workspace = true +soundgen-io.workspace = true +soundgen-seq.workspace = true +clap.workspace = true +serde_json.workspace = true + +[dev-dependencies] +hound.workspace = true diff --git a/crates/soundgen-cli/src/main.rs b/crates/soundgen-cli/src/main.rs new file mode 100644 index 0000000..8f02b1d --- /dev/null +++ b/crates/soundgen-cli/src/main.rs @@ -0,0 +1,203 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "soundgen")] +#[command(version = "0.1.0")] +#[command(about = "8-bit sound synthesizer — generate SFX/UI/ambient sounds from JSON presets")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Generate a sound from a named preset. + Gen { + /// Preset name (e.g., "jump", "explosion", "click"). + preset: String, + /// Output WAV path. + #[arg(short, long)] + out: PathBuf, + /// Override parameters (e.g., --param volume=0.9). + #[arg(short, long)] + param: Vec, + /// Presets directory (defaults to bundled presets/). + #[arg(long, default_value = "presets")] + presets_dir: PathBuf, + }, + /// Render a sound from a JSON spec file. + Render { + /// Input JSON spec file. + spec: PathBuf, + /// Output WAV path. + #[arg(short, long)] + out: PathBuf, + }, + /// Render a song from a JSON song file (sequencer). + RenderSong { + /// Input JSON song file. + song: PathBuf, + /// Output WAV path. + #[arg(short, long)] + out: PathBuf, + }, + /// List available presets. + ListPresets { + /// Filter by category: sfx, ui, or ambient. + #[arg(short, long)] + category: Option, + /// Presets directory. + #[arg(long, default_value = "presets")] + presets_dir: PathBuf, + }, +} + +fn main() { + let cli = Cli::parse(); + if let Err(e) = run(cli) { + eprintln!("error: {}", e); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<(), String> { + match cli.command { + Commands::Gen { + preset, + out, + param, + presets_dir, + } => cmd_gen(&preset, &out, ¶m, &presets_dir), + Commands::Render { spec, out } => cmd_render(&spec, &out), + Commands::RenderSong { song, out } => cmd_render_song(&song, &out), + Commands::ListPresets { + category, + presets_dir, + } => cmd_list_presets(category.as_deref(), &presets_dir), + } +} + +fn cmd_gen( + preset_name: &str, + out: &std::path::Path, + params: &[String], + presets_dir: &std::path::Path, +) -> Result<(), String> { + let registry = soundgen_fmt::PresetRegistry::load_dir(presets_dir) + .map_err(|e| format!("loading presets from {}: {}", presets_dir.display(), e))?; + + let entry = registry.get(preset_name).ok_or_else(|| { + let available = registry.names().join(", "); + format!( + "preset '{}' not found. Available: {}", + preset_name, available + ) + })?; + + let mut spec = entry.spec.clone(); + + // Apply parameter overrides + for p in params { + let (key, value) = p + .split_once('=') + .ok_or_else(|| format!("invalid --param format: '{}' (expected key=value)", p))?; + apply_param(&mut spec, key, value)?; + } + + render_and_write(&spec, out) +} + +fn cmd_render(spec_path: &std::path::Path, out: &std::path::Path) -> Result<(), String> { + let content = std::fs::read_to_string(spec_path) + .map_err(|e| format!("reading spec {}: {}", spec_path.display(), e))?; + let spec: soundgen_fmt::SoundSpec = + serde_json::from_str(&content).map_err(|e| format!("parsing spec: {}", e))?; + render_and_write(&spec, out) +} + +fn cmd_render_song(song_path: &std::path::Path, out: &std::path::Path) -> Result<(), String> { + let content = std::fs::read_to_string(song_path) + .map_err(|e| format!("reading song {}: {}", song_path.display(), e))?; + let song: soundgen_seq::Song = + serde_json::from_str(&content).map_err(|e| format!("parsing song: {}", e))?; + + let samples = soundgen_seq::render_song(&song); + soundgen_io::write_wav(out, &samples, song.sample_rate)?; + eprintln!( + "Wrote {} ({} samples, {:.1}s, {} Hz)", + out.display(), + samples.len() / 2, + song.duration(), + song.sample_rate + ); + Ok(()) +} + +fn cmd_list_presets(category: Option<&str>, presets_dir: &std::path::Path) -> Result<(), String> { + let registry = soundgen_fmt::PresetRegistry::load_dir(presets_dir) + .map_err(|e| format!("loading presets: {}", e))?; + + let cat = category.and_then(soundgen_fmt::PresetCategory::from_str); + let presets = registry.list(cat); + + if presets.is_empty() { + println!("No presets found in {}", presets_dir.display()); + return Ok(()); + } + + println!( + "{:<20} {:<10} {:<10} {}", + "NAME", "CATEGORY", "DURATION", "CHANNELS" + ); + println!("{}", "-".repeat(60)); + for p in &presets { + println!( + "{:<20} {:<10} {:<10.2} {}", + p.name, + p.category.as_str(), + p.spec.duration, + p.spec.channels.len() + ); + } + Ok(()) +} + +fn render_and_write(spec: &soundgen_fmt::SoundSpec, out: &std::path::Path) -> Result<(), String> { + let samples = soundgen_fmt::render_spec(spec); + soundgen_io::write_wav(out, &samples, spec.sample_rate)?; + eprintln!( + "Wrote {} ({} samples, {:.2}s, {} Hz)", + out.display(), + samples.len() / 2, + spec.duration, + spec.sample_rate + ); + Ok(()) +} + +fn apply_param(spec: &mut soundgen_fmt::SoundSpec, key: &str, value: &str) -> Result<(), String> { + let v: f32 = value + .parse() + .map_err(|e| format!("invalid param value '{}': {}", value, e))?; + + match key { + "duration" => spec.duration = v, + "volume" => { + for ch in &mut spec.channels { + match ch { + soundgen_fmt::ChannelSpec::Pulse { volume, .. } => *volume = v, + soundgen_fmt::ChannelSpec::Triangle { volume, .. } => *volume = v, + soundgen_fmt::ChannelSpec::Noise { volume, .. } => *volume = v, + } + } + } + _ => { + return Err(format!( + "unknown param '{}' (supported: duration, volume)", + key + )) + } + } + Ok(()) +} diff --git a/crates/soundgen-core/Cargo.toml b/crates/soundgen-core/Cargo.toml new file mode 100644 index 0000000..31b7478 --- /dev/null +++ b/crates/soundgen-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "soundgen-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/soundgen-core/src/automation.rs b/crates/soundgen-core/src/automation.rs new file mode 100644 index 0000000..2ef4f1e --- /dev/null +++ b/crates/soundgen-core/src/automation.rs @@ -0,0 +1,91 @@ +//! Frequency automation — pitch envelope / sweep configuration. +//! +//! Serializable config that drives a [`Sweep`] at render time. + +use crate::effect::{Sweep, SweepCurve}; + +/// Describes how a channel's frequency changes over its duration. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct FrequencyAutomation { + pub start: f32, + pub end: f32, + #[serde(default = "default_curve")] + pub curve: SweepCurve, +} + +fn default_curve() -> SweepCurve { + SweepCurve::Linear +} + +impl FrequencyAutomation { + /// Build a [`Sweep`] for this automation over `duration` seconds. + pub fn to_sweep(&self, sample_rate: f32, duration: f32) -> Sweep { + Sweep::new(sample_rate, self.start, self.end, self.curve, duration) + } + + /// Static frequency (no sweep). + pub fn fixed(freq: f32) -> Self { + Self { + start: freq, + end: freq, + curve: SweepCurve::Linear, + } + } + + pub fn is_static(&self) -> bool { + (self.start - self.end).abs() < 0.01 + } +} + +impl Default for FrequencyAutomation { + fn default() -> Self { + Self::fixed(440.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fixed_automation_is_static() { + let a = FrequencyAutomation::fixed(440.0); + assert!(a.is_static()); + } + + #[test] + fn test_sweep_automation_not_static() { + let a = FrequencyAutomation { + start: 200.0, + end: 800.0, + curve: SweepCurve::Exponential, + }; + assert!(!a.is_static()); + } + + #[test] + fn test_to_sweep_produces_valid_sweep() { + let a = FrequencyAutomation { + start: 100.0, + end: 200.0, + curve: SweepCurve::Linear, + }; + let mut s = a.to_sweep(100.0, 1.0); + s.trigger(); + s.tick(); + assert!((s.current() - 100.0).abs() < 2.0); + } + + #[test] + fn test_serde_roundtrip() { + let a = FrequencyAutomation { + start: 100.0, + end: 400.0, + curve: SweepCurve::Exponential, + }; + let json = serde_json::to_string(&a).unwrap(); + let a2: FrequencyAutomation = serde_json::from_str(&json).unwrap(); + assert!((a2.start - a.start).abs() < 0.01); + assert_eq!(a2.curve, a.curve); + } +} diff --git a/crates/soundgen-core/src/channel.rs b/crates/soundgen-core/src/channel.rs new file mode 100644 index 0000000..ba70f02 --- /dev/null +++ b/crates/soundgen-core/src/channel.rs @@ -0,0 +1,215 @@ +//! Channel renderer — wraps a voice with envelope, frequency sweep, and filter. +//! +//! Produces stereo samples (left, right) with volume and pan applied. + +use crate::effect::{Envelope, Filter, Sweep}; +use crate::generator::{Generator, Voice}; +use crate::voice::VoiceKind; + +pub struct ChannelRenderer { + voice: VoiceKind, + envelope: Envelope, + freq_sweep: Option, + /// Frequency used when there's no sweep (static frequency). + initial_frequency: f32, + filter: Option, + filter_sweep: Option, + volume: f32, + pan: f32, +} + +impl ChannelRenderer { + pub fn new(voice: VoiceKind, sample_rate: f32) -> Self { + Self { + voice, + envelope: Envelope::new(sample_rate), + freq_sweep: None, + initial_frequency: 440.0, + filter: None, + filter_sweep: None, + volume: 1.0, + pan: 0.0, + } + } + + pub fn with_envelope(mut self, envelope: Envelope) -> Self { + self.envelope = envelope; + self + } + + pub fn with_freq_sweep(mut self, sweep: Sweep) -> Self { + self.freq_sweep = Some(sweep); + self + } + + /// Set the static frequency (used when no sweep is present). + pub fn with_initial_frequency(mut self, freq: f32) -> Self { + self.initial_frequency = freq; + self + } + + pub fn with_filter(mut self, filter: Filter) -> Self { + self.filter = Some(filter); + self + } + + pub fn with_filter_sweep(mut self, sweep: Sweep) -> Self { + self.filter_sweep = Some(sweep); + self + } + + pub fn with_volume(mut self, volume: f32) -> Self { + self.volume = volume.clamp(0.0, 1.0); + self + } + + pub fn with_pan(mut self, pan: f32) -> Self { + self.pan = pan.clamp(-1.0, 1.0); + self + } + + /// Trigger note on + envelope + sweeps. + pub fn trigger(&mut self) { + let freq = self + .freq_sweep + .as_ref() + .map(|s| s.current()) + .unwrap_or(self.initial_frequency); + self.voice.note_on(freq, 1.0); + self.envelope.trigger(); + if let Some(s) = &mut self.freq_sweep { + s.trigger(); + } + if let Some(s) = &mut self.filter_sweep { + s.trigger(); + } + } + + /// Release the envelope (note off). + pub fn release(&mut self) { + self.envelope.release(); + } + + /// Set the voice frequency directly (overrides any sweep). + pub fn set_frequency(&mut self, freq: f32) { + if self.freq_sweep.is_none() { + self.voice.set_frequency(freq); + } + } + + /// Produce one stereo sample (left, right). + #[inline] + pub fn tick(&mut self) -> (f32, f32) { + // Update frequency from sweep + if let Some(sweep) = &mut self.freq_sweep { + let freq = sweep.tick(); + self.voice.set_frequency(freq); + } + + // Tick voice + let mut sample = self.voice.tick(); + + // Update filter cutoff from sweep + if let (Some(filter), Some(fsweep)) = (&mut self.filter, &mut self.filter_sweep) { + let cutoff = fsweep.tick(); + filter.set_cutoff(cutoff); + } + + // Apply filter + if let Some(filter) = &mut self.filter { + sample = filter.process(sample); + } + + // Apply envelope + let env = self.envelope.tick(); + sample *= env * self.volume; + + // Equal-power pan + let pan_norm = (self.pan + 1.0) * 0.5; + let left_gain = (1.0 - pan_norm).sqrt(); + let right_gain = pan_norm.sqrt(); + + (sample * left_gain, sample * right_gain) + } + + pub fn is_finished(&self) -> bool { + self.envelope.is_finished() + } + + pub fn reset(&mut self) { + self.voice.reset(); + self.envelope.reset(); + if let Some(s) = &mut self.freq_sweep { + s.reset(); + } + if let Some(s) = &mut self.filter_sweep { + s.reset(); + } + if let Some(f) = &mut self.filter { + f.reset(); + } + } +} + +/// Render multiple channel renderers to interleaved stereo Vec (L, R, L, R, ...). +pub fn render_channels(channels: &mut [ChannelRenderer], n_samples: usize) -> Vec { + let mut out = Vec::with_capacity(n_samples * 2); + for _ in 0..n_samples { + let mut left = 0.0f32; + let mut right = 0.0f32; + for ch in channels.iter_mut() { + let (l, r) = ch.tick(); + left += l; + right += r; + } + // Soft clip + out.push(left.tanh()); + out.push(right.tanh()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::voice::{DutyCycle, PulseChannel}; + + #[test] + fn test_channel_renderer_basic() { + let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50)); + let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5); + ch.trigger(); + // Envelope starts at 0 in attack phase; tick a few times to get non-zero + let mut found_signal = false; + for _ in 0..100 { + let (l, r) = ch.tick(); + if l.abs() > 0.0 || r.abs() > 0.0 { + found_signal = true; + break; + } + } + assert!(found_signal); + } + + #[test] + fn test_channel_renderer_envelope_decays() { + let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50)); + let mut ch = ChannelRenderer::new(voice, 44100.0) + .with_envelope(Envelope::adsr(44100.0, 0.0, 0.0, 0.0, 0.01)); + ch.trigger(); + // With sustain=0 and 0 decay, envelope drops to 0 immediately + let (_l, _r) = ch.tick(); + // After attack (0s) → decay (0s) → sustain (0), should be silent + let (l2, r2) = ch.tick(); + assert!(l2.abs() < 0.01 && r2.abs() < 0.01); + } + + #[test] + fn test_render_channels_length() { + let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50)); + let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5); + ch.trigger(); + let out = render_channels(&mut [ch], 500); + assert_eq!(out.len(), 1000); + } +} diff --git a/crates/soundgen-core/src/effect/envelope.rs b/crates/soundgen-core/src/effect/envelope.rs new file mode 100644 index 0000000..fccec2e --- /dev/null +++ b/crates/soundgen-core/src/effect/envelope.rs @@ -0,0 +1,214 @@ +//! ADSR envelope generator. +//! +//! Phases: Attack → Decay → Sustain → Release → Idle. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EnvelopePhase { + Idle, + Attack, + Decay, + Sustain, + Release, +} + +#[derive(Clone, Debug)] +pub struct Envelope { + sample_rate: f32, + attack: f32, + decay: f32, + sustain: f32, + release: f32, + phase: EnvelopePhase, + level: f32, + release_start: f32, + sample_counter: f32, +} + +impl Envelope { + pub fn new(sample_rate: f32) -> Self { + Self { + sample_rate, + attack: 0.01, + decay: 0.1, + sustain: 0.7, + release: 0.1, + phase: EnvelopePhase::Idle, + level: 0.0, + release_start: 0.0, + sample_counter: 0.0, + } + } + + pub fn adsr(sample_rate: f32, attack: f32, decay: f32, sustain: f32, release: f32) -> Self { + Self { + sample_rate, + attack, + decay, + sustain, + release, + phase: EnvelopePhase::Idle, + level: 0.0, + release_start: 0.0, + sample_counter: 0.0, + } + } + + pub fn trigger(&mut self) { + self.phase = EnvelopePhase::Attack; + self.level = 0.0; + self.sample_counter = 0.0; + } + + pub fn release(&mut self) { + if self.phase != EnvelopePhase::Idle { + self.phase = EnvelopePhase::Release; + self.release_start = self.level; + self.sample_counter = 0.0; + } + } + + #[inline] + fn samples_for(&self, seconds: f32) -> f32 { + seconds * self.sample_rate + } + + /// Produce next envelope amplitude [0, 1]. + #[inline] + pub fn tick(&mut self) -> f32 { + match self.phase { + EnvelopePhase::Idle => { + self.level = 0.0; + } + EnvelopePhase::Attack => { + let attack_samples = self.samples_for(self.attack); + if attack_samples > 0.0 { + self.level = self.sample_counter / attack_samples; + } else { + self.level = 1.0; + } + self.sample_counter += 1.0; + if self.level >= 1.0 || self.sample_counter >= attack_samples { + self.level = 1.0; + self.phase = EnvelopePhase::Decay; + self.sample_counter = 0.0; + } + } + EnvelopePhase::Decay => { + let decay_samples = self.samples_for(self.decay); + if decay_samples > 0.0 { + self.level = 1.0 - (1.0 - self.sustain) * (self.sample_counter / decay_samples); + } else { + self.level = self.sustain; + } + self.sample_counter += 1.0; + if self.sample_counter >= decay_samples || self.level <= self.sustain { + self.level = self.sustain; + self.phase = EnvelopePhase::Sustain; + self.sample_counter = 0.0; + } + } + EnvelopePhase::Sustain => { + self.level = self.sustain; + } + EnvelopePhase::Release => { + let release_samples = self.samples_for(self.release); + if release_samples > 0.0 { + self.level = self.release_start * (1.0 - self.sample_counter / release_samples); + } else { + self.level = 0.0; + } + self.sample_counter += 1.0; + if self.level <= 0.0 || self.sample_counter >= release_samples { + self.level = 0.0; + self.phase = EnvelopePhase::Idle; + self.sample_counter = 0.0; + } + } + } + self.level.clamp(0.0, 1.0) + } + + pub fn is_finished(&self) -> bool { + self.phase == EnvelopePhase::Idle + } + + pub fn phase(&self) -> EnvelopePhase { + self.phase + } + + pub fn reset(&mut self) { + self.phase = EnvelopePhase::Idle; + self.level = 0.0; + self.release_start = 0.0; + self.sample_counter = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_envelope_idle() { + let mut env = Envelope::new(44100.0); + assert_eq!(env.tick(), 0.0); + assert!(env.is_finished()); + } + + #[test] + fn test_envelope_attack() { + let mut env = Envelope::adsr(44100.0, 0.1, 0.1, 0.5, 0.1); + env.trigger(); + assert_eq!(env.phase(), EnvelopePhase::Attack); + // First sample should be near 0 + let s0 = env.tick(); + assert!(s0 < 0.01); + // After ~half attack, should be ~0.5 + for _ in 0..2205 { + env.tick(); + } + assert!(env.level > 0.4 && env.level < 0.6); + // After full attack, should be in decay + for _ in 0..2205 { + env.tick(); + } + assert_eq!(env.phase(), EnvelopePhase::Decay); + } + + #[test] + fn test_envelope_sustain() { + let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 0.7, 0.1); + env.trigger(); + env.tick(); // attack (0 samples → immediate) + env.tick(); // decay (0 samples → immediate) + assert_eq!(env.phase(), EnvelopePhase::Sustain); + let s = env.tick(); + assert!((s - 0.7).abs() < 0.01); + } + + #[test] + fn test_envelope_release() { + let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 1.0, 0.1); + env.trigger(); + env.tick(); + env.tick(); + assert!((env.level - 1.0).abs() < 0.01); + env.release(); + let s0 = env.tick(); + assert!(s0 <= 1.0); + // After full release, should be idle + for _ in 0..4500 { + env.tick(); + } + assert!(env.is_finished()); + } + + #[test] + fn test_zero_attack_immediate() { + let mut env = Envelope::adsr(44100.0, 0.0, 0.1, 0.5, 0.1); + env.trigger(); + env.tick(); + // With 0 attack, should immediately be at 1.0 or in decay + assert!(env.level >= 0.99 || env.phase() == EnvelopePhase::Decay); + } +} diff --git a/crates/soundgen-core/src/effect/filter.rs b/crates/soundgen-core/src/effect/filter.rs new file mode 100644 index 0000000..c71882a --- /dev/null +++ b/crates/soundgen-core/src/effect/filter.rs @@ -0,0 +1,184 @@ +//! Biquad filter — lowpass / highpass. +//! +//! Standard second-order IIR filter with coefficient calculation. + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FilterType { + Lowpass, + Highpass, +} + +/// Stateful biquad filter. Process one sample at a time. +pub struct Filter { + filter_type: FilterType, + sample_rate: f32, + cutoff: f32, + q: f32, + // Coefficients + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, + // State (Direct Form I) + x1: f32, + x2: f32, + y1: f32, + y2: f32, +} + +impl Filter { + pub fn new(sample_rate: f32, filter_type: FilterType, cutoff: f32, q: f32) -> Self { + let mut f = Self { + filter_type, + sample_rate, + cutoff, + q, + 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, + }; + f.recalc(); + f + } + + pub fn set_cutoff(&mut self, cutoff: f32) { + self.cutoff = cutoff; + self.recalc(); + } + + pub fn set_q(&mut self, q: f32) { + self.q = q; + self.recalc(); + } + + fn recalc(&mut self) { + let w0 = 2.0 * std::f32::consts::PI * self.cutoff / self.sample_rate; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * self.q); + + let (b0, b1, b2, a0, a1, a2); + + match self.filter_type { + FilterType::Lowpass => { + b0 = (1.0 - cos_w0) / 2.0; + b1 = 1.0 - cos_w0; + b2 = (1.0 - cos_w0) / 2.0; + a0 = 1.0 + alpha; + a1 = -2.0 * cos_w0; + a2 = 1.0 - alpha; + } + FilterType::Highpass => { + b0 = (1.0 + cos_w0) / 2.0; + b1 = -(1.0 + cos_w0); + b2 = (1.0 + cos_w0) / 2.0; + a0 = 1.0 + alpha; + a1 = -2.0 * cos_w0; + a2 = 1.0 - alpha; + } + } + + // Normalize by a0 + self.b0 = b0 / a0; + self.b1 = b1 / a0; + self.b2 = b2 / a0; + self.a1 = a1 / a0; + self.a2 = a2 / a0; + } + + /// Process one sample through the filter. + #[inline] + pub fn process(&mut self, input: f32) -> f32 { + 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 + } + + pub fn reset(&mut self) { + self.x1 = 0.0; + self.x2 = 0.0; + self.y1 = 0.0; + self.y2 = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lowpass_attenuates_high_freq() { + let mut lp = Filter::new(44100.0, FilterType::Lowpass, 500.0, 0.707); + // Feed high-frequency signal (alternating +1/-1 per sample = Nyquist freq) + let mut amp_high = 0.0; + for i in 0..1000 { + let input = if i % 2 == 0 { 1.0 } else { -1.0 }; + amp_high += lp.process(input).abs(); + } + + // Feed low-frequency signal (constant = DC, passes through lowpass) + let mut amp_low = 0.0; + for _ in 0..1000 { + amp_low += lp.process(1.0).abs(); + } + + // Lowpass should pass low freq better than high freq + assert!( + amp_low > amp_high, + "lowpass should attenuate high freq: low={} high={}", + amp_low, + amp_high + ); + } + + #[test] + fn test_highpass_attenuates_low_freq() { + let mut hp = Filter::new(44100.0, FilterType::Highpass, 2000.0, 0.707); + + // DC (low freq) should be attenuated + let mut amp_dc = 0.0; + for _ in 0..1000 { + let s = hp.process(1.0); + amp_dc += s.abs(); + } + + // High freq (alternating) should pass + hp.reset(); + let mut amp_hf = 0.0; + for _ in 0..500 { + let s = hp.process(1.0); + amp_hf += s.abs(); + } + for _ in 0..500 { + let s = hp.process(-1.0); + amp_hf += s.abs(); + } + + assert!(amp_hf > amp_dc, "highpass should attenuate DC"); + } + + #[test] + fn test_filter_reset() { + let mut f = Filter::new(44100.0, FilterType::Lowpass, 1000.0, 0.707); + for _ in 0..100 { + f.process(0.5); + } + f.reset(); + // After reset, state should be zero + assert_eq!(f.x1, 0.0); + assert_eq!(f.y1, 0.0); + } +} diff --git a/crates/soundgen-core/src/effect/mod.rs b/crates/soundgen-core/src/effect/mod.rs new file mode 100644 index 0000000..f7bd347 --- /dev/null +++ b/crates/soundgen-core/src/effect/mod.rs @@ -0,0 +1,11 @@ +//! Audio effects: envelope, sweep, filter, vibrato. + +pub mod envelope; +pub mod filter; +pub mod sweep; +pub mod vibrato; + +pub use envelope::{Envelope, EnvelopePhase}; +pub use filter::{Filter, FilterType}; +pub use sweep::{Sweep, SweepCurve}; +pub use vibrato::Vibrato; diff --git a/crates/soundgen-core/src/effect/sweep.rs b/crates/soundgen-core/src/effect/sweep.rs new file mode 100644 index 0000000..e31b1b2 --- /dev/null +++ b/crates/soundgen-core/src/effect/sweep.rs @@ -0,0 +1,144 @@ +//! Frequency sweep — pitch automation over time. +//! +//! Linear: freq = start + (end - start) * t +//! Exponential: freq = start * (end / start) ^ t + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SweepCurve { + Linear, + Exponential, +} + +pub struct Sweep { + sample_rate: f32, + start: f32, + end: f32, + curve: SweepCurve, + duration: f32, + sample_counter: f32, + current: f32, + active: bool, +} + +impl Sweep { + pub fn new(sample_rate: f32, start: f32, end: f32, curve: SweepCurve, duration: f32) -> Self { + Self { + sample_rate, + start, + end, + curve, + duration, + sample_counter: 0.0, + current: start, + active: false, + } + } + + pub fn trigger(&mut self) { + self.sample_counter = 0.0; + self.current = self.start; + self.active = true; + } + + pub fn stop(&mut self) { + self.active = false; + } + + #[inline] + pub fn tick(&mut self) -> f32 { + if !self.active { + return self.current; + } + let total_samples = self.duration * self.sample_rate; + if total_samples <= 0.0 { + self.current = self.end; + self.active = false; + return self.current; + } + let t = self.sample_counter / total_samples; + if t >= 1.0 { + self.current = self.end; + self.active = false; + return self.current; + } + self.current = match self.curve { + SweepCurve::Linear => self.start + (self.end - self.start) * t, + SweepCurve::Exponential => { + if self.start <= 0.0 { + self.start + (self.end - self.start) * t + } else { + self.start * (self.end / self.start).powf(t) + } + } + }; + self.sample_counter += 1.0; + self.current + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn current(&self) -> f32 { + self.current + } + + pub fn reset(&mut self) { + self.sample_counter = 0.0; + self.current = self.start; + self.active = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_linear_sweep() { + let mut sweep = Sweep::new(100.0, 100.0, 200.0, SweepCurve::Linear, 1.0); + sweep.trigger(); + // t=0 → start + let f0 = sweep.tick(); + assert!((f0 - 100.0).abs() < 2.0); + // t=0.5 → midpoint + for _ in 0..49 { + sweep.tick(); + } + assert!((sweep.current() - 150.0).abs() < 3.0); + // t=1.0 → end (need 101 ticks: tick 101 hits t=1.0) + for _ in 0..51 { + sweep.tick(); + } + assert!((sweep.current() - 200.0).abs() < 1.0); + assert!(!sweep.is_active()); + } + + #[test] + fn test_exponential_sweep() { + let mut sweep = Sweep::new(100.0, 100.0, 400.0, SweepCurve::Exponential, 1.0); + sweep.trigger(); + // t=0 → start + sweep.tick(); + assert!((sweep.current() - 100.0).abs() < 3.0); + // t=0.5 → 100 * 4^0.5 = 200 + for _ in 0..49 { + sweep.tick(); + } + assert!((sweep.current() - 200.0).abs() < 10.0); + // t=1.0 → 400 + for _ in 0..51 { + sweep.tick(); + } + assert!((sweep.current() - 400.0).abs() < 2.0); + } + + #[test] + fn test_sweep_not_active_returns_current() { + let mut sweep = Sweep::new(100.0, 200.0, 300.0, SweepCurve::Linear, 1.0); + // Not triggered + let f = sweep.tick(); + assert_eq!(f, 200.0); // current = start = 200 + } +} diff --git a/crates/soundgen-core/src/effect/vibrato.rs b/crates/soundgen-core/src/effect/vibrato.rs new file mode 100644 index 0000000..cb54616 --- /dev/null +++ b/crates/soundgen-core/src/effect/vibrato.rs @@ -0,0 +1,89 @@ +//! Vibrato — LFO for frequency modulation. +//! +//! Modulates the pitch by a sine wave at a given rate and depth. + +pub struct Vibrato { + sample_rate: f32, + freq: f32, + depth: f32, + phase: f32, + active: bool, +} + +impl Vibrato { + /// Create a vibrato with given LFO frequency (Hz) and depth (in cents). + pub fn new(sample_rate: f32, freq: f32, depth_cents: f32) -> Self { + Self { + sample_rate, + freq, + depth: depth_cents, + phase: 0.0, + active: false, + } + } + + pub fn trigger(&mut self) { + self.phase = 0.0; + self.active = true; + } + + pub fn stop(&mut self) { + self.active = false; + } + + /// Returns the frequency multiplier for the current tick. + /// Multiply this by the base frequency to get the vibrato-modulated frequency. + #[inline] + pub fn tick(&mut self) -> f32 { + if !self.active { + return 1.0; + } + let lfo = (2.0 * std::f32::consts::PI * self.phase).sin(); + self.phase += self.freq / self.sample_rate; + self.phase = self.phase.fract(); + + // Convert cents to frequency ratio: 2^(cents/1200) + let cents = lfo * self.depth; + 2.0f32.powf(cents / 1200.0) + } + + pub fn reset(&mut self) { + self.phase = 0.0; + self.active = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vibrato_inactive_returns_unity() { + let mut v = Vibrato::new(44100.0, 5.0, 50.0); + assert!((v.tick() - 1.0).abs() < 0.001); + } + + #[test] + fn test_vibrato_modulates() { + let mut v = Vibrato::new(100.0, 10.0, 100.0); // 10 Hz, 100 cents + v.trigger(); + let mut ratios = Vec::new(); + for _ in 0..100 { + ratios.push(v.tick()); + } + // Should vary from unity + let min = ratios.iter().cloned().fold(1.0f32, f32::min); + let max = ratios.iter().cloned().fold(1.0f32, f32::max); + assert!(min < 0.99, "vibrato should go below unity: min={}", min); + assert!(max > 1.01, "vibrato should go above unity: max={}", max); + } + + #[test] + fn test_vibrato_depth_zero_is_unity() { + let mut v = Vibrato::new(44100.0, 5.0, 0.0); + v.trigger(); + for _ in 0..100 { + assert!((v.tick() - 1.0).abs() < 0.001); + } + } +} diff --git a/crates/soundgen-core/src/generator.rs b/crates/soundgen-core/src/generator.rs new file mode 100644 index 0000000..cf1b667 --- /dev/null +++ b/crates/soundgen-core/src/generator.rs @@ -0,0 +1,18 @@ +//! Core traits for sound generation. + +/// A sample-by-sample generator. Produces one mono sample per `tick()`. +/// +/// Output range: -1.0 ..= 1.0. +/// Implementations must not allocate in `tick()`. +pub trait Generator { + fn tick(&mut self) -> f32; + fn reset(&mut self); +} + +/// A playable voice with note on/off semantics. +pub trait Voice: Generator { + fn note_on(&mut self, freq: f32, velocity: f32); + fn note_off(&mut self); + fn set_frequency(&mut self, freq: f32); + fn is_active(&self) -> bool; +} diff --git a/crates/soundgen-core/src/lib.rs b/crates/soundgen-core/src/lib.rs new file mode 100644 index 0000000..ce03572 --- /dev/null +++ b/crates/soundgen-core/src/lib.rs @@ -0,0 +1,20 @@ +//! Core synthesis engine: generators, effects, mixer. +//! +//! No I/O. Renders to `Vec`. + +pub mod automation; +pub mod channel; +pub mod effect; +pub mod generator; +pub mod mixer; +pub mod voice; + +pub use automation::FrequencyAutomation; +pub use channel::{render_channels, ChannelRenderer}; +pub use effect::{Envelope, EnvelopePhase, Filter, FilterType, Sweep, SweepCurve, Vibrato}; +pub use generator::{Generator, Voice}; +pub use mixer::Mixer; +pub use voice::{ + DpcmChannel, DutyCycle, FmChannel, NoiseMode, PulseChannel, TriangleChannel, VoiceKind, + WavetableChannel, +}; diff --git a/crates/soundgen-core/src/mixer.rs b/crates/soundgen-core/src/mixer.rs new file mode 100644 index 0000000..06c7635 --- /dev/null +++ b/crates/soundgen-core/src/mixer.rs @@ -0,0 +1,168 @@ +//! Stereo mixer — sums multiple voices with per-channel volume and pan. + +use crate::voice::VoiceKind; +use crate::Generator; + +/// A single channel in the mixer: voice + amplitude + pan. +pub struct MixerChannel { + pub voice: VoiceKind, + pub volume: f32, + /// -1.0 = full left, 0.0 = center, 1.0 = full right. + pub pan: f32, +} + +pub struct Mixer { + channels: Vec, + sample_rate: f32, +} + +impl Mixer { + pub fn new(sample_rate: f32) -> Self { + Self { + channels: Vec::new(), + sample_rate, + } + } + + pub fn push(&mut self, voice: VoiceKind, volume: f32, pan: f32) { + self.channels.push(MixerChannel { + voice, + volume: volume.clamp(0.0, 1.0), + pan: pan.clamp(-1.0, 1.0), + }); + } + + pub fn sample_rate(&self) -> f32 { + self.sample_rate + } + + pub fn len(&self) -> usize { + self.channels.len() + } + + pub fn is_empty(&self) -> bool { + self.channels.is_empty() + } + + pub fn reset(&mut self) { + for ch in &mut self.channels { + ch.voice.reset(); + } + } + + /// Produce one stereo sample (left, right). + #[inline] + pub fn tick_stereo(&mut self) -> (f32, f32) { + let mut left = 0.0f32; + let mut right = 0.0f32; + for ch in &mut self.channels { + let sample = ch.voice.tick() * ch.volume; + // Equal-power panning + let pan_norm = (ch.pan + 1.0) * 0.5; // 0 = left, 1 = right + let left_gain = (1.0 - pan_norm).sqrt(); + let right_gain = pan_norm.sqrt(); + left += sample * left_gain; + right += sample * right_gain; + } + // Soft clip to prevent harsh clipping + let left = left.tanh(); + let right = right.tanh(); + (left, right) + } + + /// Produce one mono sample (sum of all channels). + #[inline] + pub fn tick_mono(&mut self) -> f32 { + let (l, r) = self.tick_stereo(); + (l + r) * 0.5 + } +} + +/// Render a mixer to a stereo Vec (interleaved L, R, L, R, ...). +pub fn render_stereo(mixer: &mut Mixer, n_samples: usize) -> Vec { + let mut out = Vec::with_capacity(n_samples * 2); + for _ in 0..n_samples { + let (l, r) = mixer.tick_stereo(); + out.push(l); + out.push(r); + } + out +} + +/// Render a mixer to a mono Vec. +pub fn render_mono(mixer: &mut Mixer, n_samples: usize) -> Vec { + let mut out = Vec::with_capacity(n_samples); + for _ in 0..n_samples { + out.push(mixer.tick_mono()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generator::Voice; + use crate::voice::{DutyCycle, PulseChannel}; + + #[test] + fn test_mixer_empty() { + let mut m = Mixer::new(44100.0); + let (l, r) = m.tick_stereo(); + assert_eq!(l, 0.0); + assert_eq!(r, 0.0); + } + + #[test] + fn test_mixer_single_channel() { + let mut m = Mixer::new(44100.0); + let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50); + pulse.note_on(440.0, 1.0); + m.push(VoiceKind::Pulse(pulse), 0.5, 0.0); + let (l, r) = m.tick_stereo(); + // Center pan, volume 0.5 + assert!(l.abs() > 0.0 || r.abs() > 0.0); + } + + #[test] + fn test_mixer_pan_left() { + let mut m = Mixer::new(44100.0); + let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50); + pulse.note_on(440.0, 1.0); + m.push(VoiceKind::Pulse(pulse), 1.0, -1.0); // full left + let (l, r) = m.tick_stereo(); + assert!(l.abs() > 0.0); + // Right should be near zero (equal power pan at -1.0 → right_gain = 0) + assert!(r.abs() < 0.01); + } + + #[test] + fn test_mixer_pan_right() { + let mut m = Mixer::new(44100.0); + let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50); + pulse.note_on(440.0, 1.0); + m.push(VoiceKind::Pulse(pulse), 1.0, 1.0); // full right + let (l, r) = m.tick_stereo(); + assert!(r.abs() > 0.0); + assert!(l.abs() < 0.01); + } + + #[test] + fn test_render_mono_length() { + let mut m = Mixer::new(44100.0); + let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50); + pulse.note_on(440.0, 1.0); + m.push(VoiceKind::Pulse(pulse), 0.5, 0.0); + let out = render_mono(&mut m, 1000); + assert_eq!(out.len(), 1000); + } + + #[test] + fn test_render_stereo_length() { + let mut m = Mixer::new(44100.0); + let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50); + pulse.note_on(440.0, 1.0); + m.push(VoiceKind::Pulse(pulse), 0.5, 0.0); + let out = render_stereo(&mut m, 1000); + assert_eq!(out.len(), 2000); // interleaved + } +} diff --git a/crates/soundgen-core/src/voice/dpcm.rs b/crates/soundgen-core/src/voice/dpcm.rs new file mode 100644 index 0000000..bf969ea --- /dev/null +++ b/crates/soundgen-core/src/voice/dpcm.rs @@ -0,0 +1,191 @@ +//! DPCM channel — 8-bit sample playback (NES DPCM-style). +//! +//! Plays back pre-decoded PCM samples at a variable clock rate. +//! Supports looping and pitch control via the clock frequency. + +use crate::{Generator, Voice}; + +pub struct DpcmChannel { + sample_rate: f32, + freq: f32, + samples: Vec, + position: f32, + velocity: f32, + active: bool, + looping: bool, +} + +impl DpcmChannel { + pub fn new(sample_rate: f32) -> Self { + Self { + sample_rate, + freq: 8000.0, + samples: vec![], + position: 0.0, + velocity: 0.0, + active: false, + looping: false, + } + } + + /// Load 8-bit unsigned PCM samples (0-255). + pub fn load_pcm_u8(&mut self, data: &[u8]) { + self.samples = data.iter().map(|&b| (b as f32 / 128.0) - 1.0).collect(); + } + + /// Load float samples (-1..1). + pub fn load_samples(&mut self, data: &[f32]) { + self.samples = data.to_vec(); + } + + pub fn set_looping(&mut self, looping: bool) { + self.looping = looping; + } + + /// Generate a simple kick drum sample. + pub fn kick() -> Vec { + let mut data = Vec::new(); + let n = 2000; + for i in 0..n { + let t = i as f32 / n as f32; + let freq = 150.0 * (1.0 - t * 0.8); + let env = (1.0 - t).powi(2); + let s = (t * freq * 2.0 * std::f32::consts::PI).sin() * env; + data.push(((s * 0.8 + 1.0) * 128.0) as u8); + } + data + } + + /// Generate a simple snare sample. + pub fn snare() -> Vec { + let mut data = Vec::new(); + let n = 1500; + let mut lfsr: u16 = 0x1234; + for i in 0..n { + let t = i as f32 / n as f32; + let env = (1.0 - t).powi(1); + let bit0 = lfsr & 1; + let bit8 = (lfsr >> 8) & 1; + lfsr >>= 1; + lfsr |= (bit0 ^ bit8) << 14; + let noise = if (lfsr & 1) == 0 { 1.0 } else { -1.0 }; + let tone = (t * 200.0 * 2.0 * std::f32::consts::PI).sin() * 0.3; + let s = (noise * 0.7 + tone) * env; + data.push(((s * 0.7 + 1.0) * 128.0) as u8); + } + data + } +} + +impl Generator for DpcmChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active || self.samples.is_empty() { + return 0.0; + } + + let idx = self.position as usize; + if idx >= self.samples.len() { + if self.looping { + self.position = 0.0; + } else { + self.active = false; + return 0.0; + } + } + + let sample = self.samples[self.position as usize % self.samples.len()]; + self.position += self.freq / self.sample_rate; + sample * self.velocity + } + + fn reset(&mut self) { + self.position = 0.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for DpcmChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.position = 0.0; + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpcm_empty() { + let mut ch = DpcmChannel::new(44100.0); + ch.note_on(8000.0, 1.0); + assert_eq!(ch.tick(), 0.0); + } + + #[test] + fn test_dpcm_playback() { + let mut ch = DpcmChannel::new(44100.0); + ch.load_samples(&[0.5, -0.5, 0.5, -0.5]); + ch.note_on(44100.0, 1.0); // 1 sample per tick + assert!((ch.tick() - 0.5).abs() < 0.01); + assert!((ch.tick() - (-0.5)).abs() < 0.01); + } + + #[test] + fn test_dpcm_loop() { + let mut ch = DpcmChannel::new(44100.0); + ch.load_samples(&[0.5, -0.5]); + ch.set_looping(true); + ch.note_on(44100.0, 1.0); // 1 sample per tick + // tick 1: pos=0 → 0.5, pos→1 + assert!((ch.tick() - 0.5).abs() < 0.01); + // tick 2: pos=1 → -0.5, pos→2 + assert!((ch.tick() - (-0.5)).abs() < 0.01); + // tick 3: pos=2 ≥ len → wrap to 0 → 0.5, pos→1 + assert!((ch.tick() - 0.5).abs() < 0.01); + assert!(ch.is_active()); + // tick 4: pos=1 → -0.5 + assert!((ch.tick() - (-0.5)).abs() < 0.01); + } + + #[test] + fn test_dpcm_no_loop_ends() { + let mut ch = DpcmChannel::new(44100.0); + ch.load_samples(&[0.5, -0.5]); + ch.note_on(44100.0, 1.0); + ch.tick(); + ch.tick(); + ch.tick(); // past end + assert!(!ch.is_active()); + } + + #[test] + fn test_kick_sample() { + let kick = DpcmChannel::kick(); + assert!(!kick.is_empty()); + assert!(kick.len() > 500); + } + + #[test] + fn test_snare_sample() { + let snare = DpcmChannel::snare(); + assert!(!snare.is_empty()); + assert!(snare.len() > 500); + } +} diff --git a/crates/soundgen-core/src/voice/fm.rs b/crates/soundgen-core/src/voice/fm.rs new file mode 100644 index 0000000..f87f944 --- /dev/null +++ b/crates/soundgen-core/src/voice/fm.rs @@ -0,0 +1,137 @@ +//! FM channel — 2-operator FM synthesis (carrier + modulator). +//! +//! carrier_out = sin(car_phase + mod_index * sin(mod_phase)) +//! mod_freq = car_freq * mod_ratio + +use crate::{Generator, Voice}; + +pub struct FmChannel { + sample_rate: f32, + freq: f32, + car_phase: f32, + mod_phase: f32, + mod_ratio: f32, + mod_index: f32, + velocity: f32, + active: bool, +} + +impl FmChannel { + pub fn new(sample_rate: f32) -> Self { + Self { + sample_rate, + freq: 440.0, + car_phase: 0.0, + mod_phase: 0.0, + mod_ratio: 2.0, // 2:1 ratio (classic FM) + mod_index: 1.0, // moderate modulation + velocity: 0.0, + active: false, + } + } + + pub fn set_mod_ratio(&mut self, ratio: f32) { + self.mod_ratio = ratio; + } + + pub fn set_mod_index(&mut self, index: f32) { + self.mod_index = index; + } + + #[inline] + fn advance(&mut self) { + let car_inc = self.freq / self.sample_rate; + let mod_inc = self.freq * self.mod_ratio / self.sample_rate; + self.car_phase = (self.car_phase + car_inc).fract(); + self.mod_phase = (self.mod_phase + mod_inc).fract(); + } +} + +impl Generator for FmChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active { + return 0.0; + } + self.advance(); + let modulator = self.mod_index * (2.0 * std::f32::consts::PI * self.mod_phase).sin(); + let carrier = (2.0 * std::f32::consts::PI * self.car_phase + modulator).sin(); + carrier * self.velocity + } + + fn reset(&mut self) { + self.car_phase = 0.0; + self.mod_phase = 0.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for FmChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fm_output_range() { + let mut ch = FmChannel::new(44100.0); + ch.set_mod_index(0.0); // no modulation → pure sine + ch.note_on(440.0, 1.0); + for _ in 0..1000 { + let s = ch.tick(); + assert!(s >= -1.01 && s <= 1.01); + } + } + + #[test] + fn test_fm_inactive() { + let mut ch = FmChannel::new(44100.0); + assert_eq!(ch.tick(), 0.0); + } + + #[test] + fn test_fm_with_modulation() { + let mut ch = FmChannel::new(44100.0); + ch.set_mod_ratio(3.0); + ch.set_mod_index(2.0); + ch.note_on(440.0, 1.0); + + // With modulation, output should have harmonics + let mut max_sample = 0.0f32; + for _ in 0..4410 { + let s = ch.tick(); + max_sample = max_sample.max(s.abs()); + } + assert!(max_sample > 0.5, "FM should produce audible output"); + } + + #[test] + fn test_fm_zero_mod_is_sine() { + let mut ch = FmChannel::new(44100.0); + ch.set_mod_index(0.0); + ch.note_on(440.0, 1.0); + // With 0 mod index, it's a pure sine wave + let s = ch.tick(); + // First sample: car_phase ≈ 0, sin(0) ≈ 0 + assert!(s.abs() < 0.1); + } +} diff --git a/crates/soundgen-core/src/voice/mod.rs b/crates/soundgen-core/src/voice/mod.rs new file mode 100644 index 0000000..7d2c658 --- /dev/null +++ b/crates/soundgen-core/src/voice/mod.rs @@ -0,0 +1,99 @@ +//! Voice implementations: pulse, triangle, noise, dpcm, wavetable, fm. + +pub mod dpcm; +pub mod fm; +pub mod noise; +pub mod pulse; +pub mod triangle; +pub mod wavetable; + +pub use dpcm::DpcmChannel; +pub use fm::FmChannel; +pub use noise::{NoiseChannel, NoiseMode}; +pub use pulse::{DutyCycle, PulseChannel}; +pub use triangle::TriangleChannel; +pub use wavetable::WavetableChannel; + +/// Discriminated union of all voice types. +/// Avoids dynamic dispatch — dispatch is a `match` in `tick()`. +pub enum VoiceKind { + Pulse(PulseChannel), + Triangle(TriangleChannel), + Noise(NoiseChannel), + Dpcm(DpcmChannel), + Wavetable(WavetableChannel), + Fm(FmChannel), +} + +impl crate::Generator for VoiceKind { + #[inline] + fn tick(&mut self) -> f32 { + match self { + VoiceKind::Pulse(v) => v.tick(), + VoiceKind::Triangle(v) => v.tick(), + VoiceKind::Noise(v) => v.tick(), + VoiceKind::Dpcm(v) => v.tick(), + VoiceKind::Wavetable(v) => v.tick(), + VoiceKind::Fm(v) => v.tick(), + } + } + + fn reset(&mut self) { + match self { + VoiceKind::Pulse(v) => v.reset(), + VoiceKind::Triangle(v) => v.reset(), + VoiceKind::Noise(v) => v.reset(), + VoiceKind::Dpcm(v) => v.reset(), + VoiceKind::Wavetable(v) => v.reset(), + VoiceKind::Fm(v) => v.reset(), + } + } +} + +impl crate::Voice for VoiceKind { + #[inline] + fn note_on(&mut self, freq: f32, velocity: f32) { + match self { + VoiceKind::Pulse(v) => v.note_on(freq, velocity), + VoiceKind::Triangle(v) => v.note_on(freq, velocity), + VoiceKind::Noise(v) => v.note_on(freq, velocity), + VoiceKind::Dpcm(v) => v.note_on(freq, velocity), + VoiceKind::Wavetable(v) => v.note_on(freq, velocity), + VoiceKind::Fm(v) => v.note_on(freq, velocity), + } + } + + fn note_off(&mut self) { + match self { + VoiceKind::Pulse(v) => v.note_off(), + VoiceKind::Triangle(v) => v.note_off(), + VoiceKind::Noise(v) => v.note_off(), + VoiceKind::Dpcm(v) => v.note_off(), + VoiceKind::Wavetable(v) => v.note_off(), + VoiceKind::Fm(v) => v.note_off(), + } + } + + #[inline] + fn set_frequency(&mut self, freq: f32) { + match self { + VoiceKind::Pulse(v) => v.set_frequency(freq), + VoiceKind::Triangle(v) => v.set_frequency(freq), + VoiceKind::Noise(v) => v.set_frequency(freq), + VoiceKind::Dpcm(v) => v.set_frequency(freq), + VoiceKind::Wavetable(v) => v.set_frequency(freq), + VoiceKind::Fm(v) => v.set_frequency(freq), + } + } + + fn is_active(&self) -> bool { + match self { + VoiceKind::Pulse(v) => v.is_active(), + VoiceKind::Triangle(v) => v.is_active(), + VoiceKind::Noise(v) => v.is_active(), + VoiceKind::Dpcm(v) => v.is_active(), + VoiceKind::Wavetable(v) => v.is_active(), + VoiceKind::Fm(v) => v.is_active(), + } + } +} diff --git a/crates/soundgen-core/src/voice/noise.rs b/crates/soundgen-core/src/voice/noise.rs new file mode 100644 index 0000000..f612e48 --- /dev/null +++ b/crates/soundgen-core/src/voice/noise.rs @@ -0,0 +1,164 @@ +//! Noise channel with LFSR — NES-style percussion. +//! +//! 15-bit Linear Feedback Shift Register. +//! White noise mode: long period (~32767 samples). +//! Periodic mode: short period (~93 samples). + +use crate::{Generator, Voice}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoiseMode { + White, + Periodic, +} + +pub struct NoiseChannel { + sample_rate: f32, + freq: f32, + phase: f32, + lfsr: u16, + current_bit: f32, + velocity: f32, + active: bool, + mode: NoiseMode, +} + +impl NoiseChannel { + pub fn new(sample_rate: f32, mode: NoiseMode) -> Self { + Self { + sample_rate, + freq: 440.0, + phase: 0.0, + lfsr: 1, + current_bit: 1.0, + velocity: 0.0, + active: false, + mode, + } + } + + pub fn set_mode(&mut self, mode: NoiseMode) { + self.mode = mode; + } + + /// Clock the LFSR one step. + #[inline] + fn clock_lfsr(&mut self) { + let bit0 = (self.lfsr & 1) as u16; + let feedback_bit = match self.mode { + NoiseMode::White => 8, // bit 8 + NoiseMode::Periodic => 6, // bit 6 + }; + let bit_n = (self.lfsr >> feedback_bit) & 1; + let feedback = bit0 ^ bit_n; + self.lfsr >>= 1; + self.lfsr |= feedback << 14; + self.current_bit = if (self.lfsr & 1) == 0 { 1.0 } else { -1.0 }; + } + + #[inline] + fn advance(&mut self) { + self.phase += self.freq / self.sample_rate; + if self.phase >= 1.0 { + self.phase = self.phase.fract(); + self.clock_lfsr(); + } + } +} + +impl Generator for NoiseChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active { + return 0.0; + } + self.advance(); + self.current_bit * self.velocity + } + + fn reset(&mut self) { + self.phase = 0.0; + self.lfsr = 1; + self.current_bit = 1.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for NoiseChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_noise_output_range() { + let mut ch = NoiseChannel::new(44100.0, NoiseMode::White); + ch.note_on(8000.0, 1.0); + for _ in 0..10000 { + let s = ch.tick(); + assert!(s == 1.0 || s == -1.0 || s == 0.0); + } + } + + #[test] + fn test_noise_inactive() { + let mut ch = NoiseChannel::new(44100.0, NoiseMode::White); + assert_eq!(ch.tick(), 0.0); + } + + #[test] + fn test_lfsr_not_stuck() { + let mut ch = NoiseChannel::new(44100.0, NoiseMode::White); + ch.note_on(44100.0, 1.0); // clock every sample + let mut values: Vec = Vec::new(); + for _ in 0..1000 { + values.push(ch.tick()); + } + let positives = values.iter().filter(|&&v| v > 0.0).count(); + let negatives = values.iter().filter(|&&v| v < 0.0).count(); + // Should produce both positive and negative values + assert!(positives > 100 && negatives > 100); + } + + #[test] + fn test_periodic_mode_shorter_period() { + let mut white = NoiseChannel::new(44100.0, NoiseMode::White); + white.note_on(44100.0, 1.0); + let mut periodic = NoiseChannel::new(44100.0, NoiseMode::Periodic); + periodic.note_on(44100.0, 1.0); + + // Collect unique patterns + let mut white_vals = Vec::new(); + let mut periodic_vals = Vec::new(); + for _ in 0..200 { + white_vals.push(white.tick()); + periodic_vals.push(periodic.tick()); + } + + // Periodic should have fewer unique transitions (shorter loop) + let white_transitions = white_vals.windows(2).filter(|w| w[0] != w[1]).count(); + let periodic_transitions = periodic_vals.windows(2).filter(|w| w[0] != w[1]).count(); + // Both should have some transitions + assert!(white_transitions > 0); + assert!(periodic_transitions > 0); + } +} diff --git a/crates/soundgen-core/src/voice/pulse.rs b/crates/soundgen-core/src/voice/pulse.rs new file mode 100644 index 0000000..528a2c8 --- /dev/null +++ b/crates/soundgen-core/src/voice/pulse.rs @@ -0,0 +1,154 @@ +//! Pulse / square wave channel with selectable duty cycle. +//! +//! Emulates the NES 2A03 pulse channels: phase accumulator + duty comparator. + +use crate::{Generator, Voice}; + +/// NES duty cycle presets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DutyCycle { + D12_5, + D25, + D50, + D75, +} + +impl DutyCycle { + pub fn from_percent(p: u8) -> Self { + match p { + 0..=12 => DutyCycle::D12_5, + 13..=37 => DutyCycle::D25, + 38..=62 => DutyCycle::D50, + _ => DutyCycle::D75, + } + } + + pub fn as_fraction(self) -> f32 { + match self { + DutyCycle::D12_5 => 0.125, + DutyCycle::D25 => 0.25, + DutyCycle::D50 => 0.5, + DutyCycle::D75 => 0.75, + } + } +} + +pub struct PulseChannel { + sample_rate: f32, + freq: f32, + phase: f32, + duty: DutyCycle, + velocity: f32, + active: bool, +} + +impl PulseChannel { + pub fn new(sample_rate: f32, duty: DutyCycle) -> Self { + Self { + sample_rate, + freq: 440.0, + phase: 0.0, + duty, + velocity: 0.0, + active: false, + } + } + + pub fn set_duty(&mut self, duty: DutyCycle) { + self.duty = duty; + } + + #[inline] + fn advance(&mut self) { + self.phase += self.freq / self.sample_rate; + self.phase = self.phase.fract(); + } +} + +impl Generator for PulseChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active { + return 0.0; + } + self.advance(); + let raw = if self.phase < self.duty.as_fraction() { + 1.0 + } else { + -1.0 + }; + raw * self.velocity + } + + fn reset(&mut self) { + self.phase = 0.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for PulseChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_duty_cycle_from_percent() { + assert_eq!(DutyCycle::from_percent(12), DutyCycle::D12_5); + assert_eq!(DutyCycle::from_percent(25), DutyCycle::D25); + assert_eq!(DutyCycle::from_percent(50), DutyCycle::D50); + assert_eq!(DutyCycle::from_percent(75), DutyCycle::D75); + } + + #[test] + fn test_pulse_output_range() { + let mut ch = PulseChannel::new(44100.0, DutyCycle::D50); + ch.note_on(440.0, 1.0); + for _ in 0..1000 { + let s = ch.tick(); + assert!(s == 1.0 || s == -1.0 || s == 0.0); + } + } + + #[test] + fn test_pulse_inactive() { + let mut ch = PulseChannel::new(44100.0, DutyCycle::D50); + assert_eq!(ch.tick(), 0.0); + } + + #[test] + fn test_pulse_duty12_5_is_mostly_negative() { + let mut ch = PulseChannel::new(44100.0, DutyCycle::D12_5); + ch.note_on(100.0, 1.0); + let mut positive = 0; + let mut negative = 0; + for _ in 0..44100 { + if ch.tick() > 0.0 { + positive += 1; + } else { + negative += 1; + } + } + // 12.5% duty → ~12.5% positive, ~87.5% negative + assert!(positive < negative, "12.5% duty should be mostly negative"); + } +} diff --git a/crates/soundgen-core/src/voice/triangle.rs b/crates/soundgen-core/src/voice/triangle.rs new file mode 100644 index 0000000..1709cf8 --- /dev/null +++ b/crates/soundgen-core/src/voice/triangle.rs @@ -0,0 +1,108 @@ +//! Triangle wave channel — NES-style bass. +//! +//! 32-step triangle via phase accumulator. Linear, no aliasing reduction +//! (acceptable for 8-bit aesthetic). + +use crate::{Generator, Voice}; + +pub struct TriangleChannel { + sample_rate: f32, + freq: f32, + phase: f32, + velocity: f32, + active: bool, +} + +impl TriangleChannel { + pub fn new(sample_rate: f32) -> Self { + Self { + sample_rate, + freq: 220.0, + phase: 0.0, + velocity: 0.0, + active: false, + } + } + + #[inline] + fn advance(&mut self) { + self.phase += self.freq / self.sample_rate; + self.phase = self.phase.fract(); + } + + /// Triangle wave from phase [0, 1). + #[inline] + fn triangle(phase: f32) -> f32 { + if phase < 0.5 { + 4.0 * phase - 1.0 // -1 → 1 + } else { + 3.0 - 4.0 * phase // 1 → -1 + } + } +} + +impl Generator for TriangleChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active { + return 0.0; + } + self.advance(); + Self::triangle(self.phase) * self.velocity + } + + fn reset(&mut self) { + self.phase = 0.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for TriangleChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_triangle_waveform() { + assert!((TriangleChannel::triangle(0.0) - (-1.0)).abs() < 1e-6); + assert!((TriangleChannel::triangle(0.25) - 0.0).abs() < 1e-6); + assert!((TriangleChannel::triangle(0.5) - 1.0).abs() < 1e-6); + assert!((TriangleChannel::triangle(0.75) - 0.0).abs() < 1e-6); + } + + #[test] + fn test_triangle_output_range() { + let mut ch = TriangleChannel::new(44100.0); + ch.note_on(440.0, 1.0); + for _ in 0..1000 { + let s = ch.tick(); + assert!(s >= -1.01 && s <= 1.01); + } + } + + #[test] + fn test_triangle_inactive() { + let mut ch = TriangleChannel::new(44100.0); + assert_eq!(ch.tick(), 0.0); + } +} diff --git a/crates/soundgen-core/src/voice/wavetable.rs b/crates/soundgen-core/src/voice/wavetable.rs new file mode 100644 index 0000000..76fedae --- /dev/null +++ b/crates/soundgen-core/src/voice/wavetable.rs @@ -0,0 +1,128 @@ +//! Wavetable channel — Game Boy wave-style 32-step wavetable. +//! +//! Each step is a value 0-15 (4-bit), normalized to -1..1. +//! The channel cycles through the wavetable at the given frequency. + +use crate::{Generator, Voice}; + +/// Default Game Boy triangle-like wavetable. +const DEFAULT_WAVE: [u8; 32] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, + 3, 2, 1, 0, +]; + +pub struct WavetableChannel { + sample_rate: f32, + freq: f32, + phase: f32, + wave: [f32; 32], + velocity: f32, + active: bool, +} + +impl WavetableChannel { + pub fn new(sample_rate: f32) -> Self { + let mut wave = [0.0f32; 32]; + for (i, &v) in DEFAULT_WAVE.iter().enumerate() { + wave[i] = (v as f32 / 7.5) - 1.0; // 0-15 → -1..1 + } + Self { + sample_rate, + freq: 440.0, + phase: 0.0, + wave, + velocity: 0.0, + active: false, + } + } + + /// Set a custom wavetable from 4-bit values (0-15). + pub fn set_wave(&mut self, wave: &[u8; 32]) { + for (i, &v) in wave.iter().enumerate() { + self.wave[i] = (v as f32 / 7.5) - 1.0; + } + } + + /// Set a custom wavetable from float values (-1..1). + pub fn set_wave_f32(&mut self, wave: &[f32; 32]) { + self.wave = *wave; + } + + #[inline] + fn advance(&mut self) { + self.phase += self.freq * 32.0 / self.sample_rate; + self.phase = self.phase.fract(); + } +} + +impl Generator for WavetableChannel { + #[inline] + fn tick(&mut self) -> f32 { + if !self.active { + return 0.0; + } + let step = (self.phase * 32.0) as usize % 32; + let sample = self.wave[step] * self.velocity; + self.advance(); + sample + } + + fn reset(&mut self) { + self.phase = 0.0; + self.velocity = 0.0; + self.active = false; + } +} + +impl Voice for WavetableChannel { + fn note_on(&mut self, freq: f32, velocity: f32) { + self.freq = freq; + self.velocity = velocity.clamp(0.0, 1.0); + self.active = true; + } + + fn note_off(&mut self) { + self.active = false; + } + + fn set_frequency(&mut self, freq: f32) { + self.freq = freq; + } + + fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wavetable_output_range() { + let mut ch = WavetableChannel::new(44100.0); + ch.note_on(440.0, 1.0); + for _ in 0..1000 { + let s = ch.tick(); + assert!(s >= -1.01 && s <= 1.01); + } + } + + #[test] + fn test_wavetable_inactive() { + let mut ch = WavetableChannel::new(44100.0); + assert_eq!(ch.tick(), 0.0); + } + + #[test] + fn test_custom_wavetable() { + let mut ch = WavetableChannel::new(44100.0); + let mut wave = [0u8; 32]; + wave[0] = 15; // peak at step 0 + ch.set_wave(&wave); + ch.note_on(100.0, 1.0); + // First sample should be near max (step 0) + let s = ch.tick(); + assert!(s > 0.5); + } +} diff --git a/crates/soundgen-fmt/Cargo.toml b/crates/soundgen-fmt/Cargo.toml new file mode 100644 index 0000000..8f1d73b --- /dev/null +++ b/crates/soundgen-fmt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "soundgen-fmt" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +soundgen-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +soundgen-io.workspace = true +soundgen-seq.workspace = true +hound.workspace = true diff --git a/crates/soundgen-fmt/examples/generate_sfx.rs b/crates/soundgen-fmt/examples/generate_sfx.rs new file mode 100644 index 0000000..ef80e45 --- /dev/null +++ b/crates/soundgen-fmt/examples/generate_sfx.rs @@ -0,0 +1,51 @@ +//! Example: generate SFX sounds from JSON presets and write to WAV. + +use soundgen_fmt::{render_spec, PresetCategory, PresetRegistry}; +use soundgen_io::write_wav; +use std::path::Path; + +fn main() { + let presets_dir = Path::new("presets"); + + let registry = PresetRegistry::load_dir(presets_dir).unwrap_or_else(|e| { + eprintln!( + "Warning: could not load presets from {}: {}", + presets_dir.display(), + e + ); + PresetRegistry::new() + }); + + if registry.is_empty() { + eprintln!("No presets found. Run from the project root directory."); + std::process::exit(1); + } + + // Generate all SFX presets + let sfx = registry.list(Some(PresetCategory::Sfx)); + let ui = registry.list(Some(PresetCategory::Ui)); + + let out_dir = Path::new("sfx_output"); + std::fs::create_dir_all(out_dir).expect("create output dir"); + + for entry in sfx.iter().chain(ui.iter()) { + let samples = render_spec(&entry.spec); + let filename = format!("{}.wav", entry.name); + let out_path = out_dir.join(&filename); + write_wav(&out_path, &samples, entry.spec.sample_rate) + .unwrap_or_else(|e| eprintln!("Failed to write {}: {}", out_path.display(), e)); + eprintln!( + " {} → {} ({} samples, {:.2}s)", + entry.name, + out_path.display(), + samples.len() / 2, + entry.spec.duration + ); + } + + eprintln!( + "\nGenerated {} sounds to {}", + sfx.len() + ui.len(), + out_dir.display() + ); +} diff --git a/crates/soundgen-fmt/examples/play_melody.rs b/crates/soundgen-fmt/examples/play_melody.rs new file mode 100644 index 0000000..9c17315 --- /dev/null +++ b/crates/soundgen-fmt/examples/play_melody.rs @@ -0,0 +1,91 @@ +//! Example: render a simple melody (pulse lead + triangle bass + noise hihat) to WAV. + +use soundgen_core::{ + voice::{DutyCycle, NoiseChannel, NoiseMode, PulseChannel, TriangleChannel}, + ChannelRenderer, Envelope, VoiceKind, +}; +use soundgen_io::write_wav; + +fn note_freq(n: &str) -> f32 { + // Note name to frequency: e.g., "A4" = 440 Hz + let notes = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let note = &n[..n.len() - 1]; + let octave: i32 = n[n.len() - 1..].parse().unwrap(); + let semitone = notes.iter().position(|&x| x == note).unwrap() as i32; + let midi = 12 * (octave + 1) + semitone; + 440.0 * 2.0f32.powf((midi - 69) as f32 / 12.0) +} + +fn main() { + let sr = 44100u32; + let bpm = 120.0; + let beat = 60.0 / bpm; // seconds per beat + let total_beats = 8.0; + let total_samples = (beat * total_beats * sr as f32) as usize; + + // Melody: C4 E4 G4 C5 G4 E4 C4 G3 + let melody = ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "G3"]; + // Bass: C3 C3 G3 G3 A2 A2 F2 F2 + let bass = ["C3", "C3", "G3", "G3", "A2", "A2", "F2", "F2"]; + + let samples_per_note = (beat * sr as f32) as usize; + + let mut stereo = Vec::with_capacity(total_samples * 2); + + for i in 0..8 { + // Lead: pulse wave + let mut lead = ChannelRenderer::new( + VoiceKind::Pulse(PulseChannel::new(sr as f32, DutyCycle::D50)), + sr as f32, + ) + .with_envelope(Envelope::adsr(sr as f32, 0.01, 0.05, 0.6, 0.08)) + .with_volume(0.3) + .with_pan(-0.3); + + lead.trigger(); + lead.set_frequency(note_freq(melody[i])); + + // Bass: triangle + let mut bass_ch = ChannelRenderer::new( + VoiceKind::Triangle(TriangleChannel::new(sr as f32)), + sr as f32, + ) + .with_envelope(Envelope::adsr(sr as f32, 0.02, 0.1, 0.5, 0.1)) + .with_volume(0.35) + .with_pan(0.3); + + bass_ch.trigger(); + bass_ch.set_frequency(note_freq(bass[i])); + + // Hihat: noise on offbeats + let has_hihat = i % 2 == 1; + let mut hihat = ChannelRenderer::new( + VoiceKind::Noise(NoiseChannel::new(sr as f32, NoiseMode::White)), + sr as f32, + ) + .with_envelope(Envelope::adsr(sr as f32, 0.0, 0.02, 0.0, 0.03)) + .with_volume(0.15) + .with_pan(0.0); + + if has_hihat { + hihat.trigger(); + hihat.set_frequency(10000.0); + } + + for _ in 0..samples_per_note { + let (ll, lr) = lead.tick(); + let (bl, br) = bass_ch.tick(); + let (hl, hr) = hihat.tick(); + let l = (ll + bl + hl).tanh(); + let r = (lr + br + hr).tanh(); + stereo.push(l); + stereo.push(r); + } + } + + let out = std::path::Path::new("play_melody.wav"); + write_wav(out, &stereo, sr).expect("failed to write WAV"); + eprintln!("Wrote {} ({} notes, {:.1}s)", out.display(), 8, beat * 8.0); +} diff --git a/crates/soundgen-fmt/examples/render_song.rs b/crates/soundgen-fmt/examples/render_song.rs new file mode 100644 index 0000000..307f8d6 --- /dev/null +++ b/crates/soundgen-fmt/examples/render_song.rs @@ -0,0 +1,112 @@ +//! Example: render a song (JSON) to WAV using the sequencer. +//! +//! Song JSON format: +//! ```json +//! { +//! "bpm": 120, +//! "rows_per_beat": 4, +//! "tracks": [ +//! { "type": "pulse", "duty": 50, "volume": 0.4 } +//! ], +//! "patterns": [ +//! { "rows": [ { "notes": [{"frequency": 440}] }, {"notes": [null]} ] } +//! ], +//! "pattern_order": [0] +//! } +//! ``` + +use soundgen_io::write_wav; +use soundgen_seq::{render_song, Song}; + +fn main() { + // Build a simple chiptune melody programmatically + let song_json = r#"{ + "bpm": 130, + "rows_per_beat": 4, + "sample_rate": 44100, + "tracks": [ + { + "type": "pulse", "duty": 50, + "volume": 0.35, "pan": -0.2, + "envelope": { "attack": 0.005, "decay": 0.03, "sustain": 0.6, "release": 0.05 } + }, + { + "type": "triangle", + "volume": 0.4, "pan": 0.3, + "envelope": { "attack": 0.01, "decay": 0.05, "sustain": 0.5, "release": 0.08 } + }, + { + "type": "noise", "mode": "white", "frequency": 8000, + "volume": 0.15, "pan": 0.0, + "envelope": { "attack": 0.0, "decay": 0.02, "sustain": 0.0, "release": 0.02 } + } + ], + "patterns": [ + { + "rows": [ + { "notes": [ + {"frequency": 523.25, "velocity": 1.0}, + {"frequency": 261.63, "velocity": 1.0}, + {"frequency": 8000, "velocity": 1.0} + ]}, + { "notes": [null, null, null] }, + { "notes": [ + {"frequency": 659.25, "velocity": 1.0}, + {"frequency": 261.63, "velocity": 1.0}, + null + ]}, + { "notes": [null, null, {"frequency": 8000, "velocity": 1.0}] }, + + { "notes": [ + {"frequency": 783.99, "velocity": 1.0}, + {"frequency": 329.63, "velocity": 1.0}, + null + ]}, + { "notes": [null, null, null] }, + { "notes": [ + {"frequency": 1046.50, "velocity": 1.0}, + {"frequency": 329.63, "velocity": 1.0}, + {"frequency": 8000, "velocity": 1.0} + ]}, + { "notes": [null, null, null] }, + + { "notes": [ + {"frequency": 659.25, "velocity": 1.0}, + {"frequency": 196.00, "velocity": 1.0}, + null + ]}, + { "notes": [null, null, {"frequency": 8000, "velocity": 1.0}] }, + { "notes": [ + {"frequency": 587.33, "velocity": 1.0}, + {"frequency": 196.00, "velocity": 1.0}, + null + ]}, + { "notes": [null, null, null] }, + + { "notes": [ + {"frequency": 523.25, "velocity": 1.0}, + {"frequency": 261.63, "velocity": 1.0}, + {"frequency": 8000, "velocity": 1.0} + ]}, + { "notes": [null, null, null] }, + { "notes": [null, null, null] }, + { "notes": [null, null, null] } + ] + } + ], + "pattern_order": [0, 0] + }"#; + + let song: Song = serde_json::from_str(song_json).expect("parse song"); + let samples = render_song(&song); + + let out = std::path::Path::new("render_song.wav"); + write_wav(out, &samples, song.sample_rate).expect("write WAV"); + eprintln!( + "Wrote {} ({:.1}s, {} patterns, {} tracks)", + out.display(), + song.duration(), + song.pattern_order.len(), + song.tracks.len() + ); +} diff --git a/crates/soundgen-fmt/src/lib.rs b/crates/soundgen-fmt/src/lib.rs new file mode 100644 index 0000000..e0691d6 --- /dev/null +++ b/crates/soundgen-fmt/src/lib.rs @@ -0,0 +1,279 @@ +//! SoundSpec — declarative JSON format for describing sounds. +//! +//! LLM-friendly: every sound is a JSON object that can be rendered to WAV. + +pub mod preset; +pub mod renderer; + +pub use preset::{PresetCategory, PresetEntry, PresetRegistry}; +pub use renderer::render_spec; + +// Re-export core types that are part of the SoundSpec format +pub use soundgen_core::SweepCurve; + +use soundgen_core::FrequencyAutomation; + +/// Top-level sound specification. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SoundSpec { + pub name: String, + pub duration: f32, + #[serde(default = "default_sample_rate")] + pub sample_rate: u32, + #[serde(default)] + pub channels: Vec, +} + +/// ADSR envelope spec. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct EnvelopeSpec { + #[serde(default)] + pub attack: f32, + #[serde(default)] + pub decay: f32, + #[serde(default = "default_sustain")] + pub sustain: f32, + #[serde(default)] + pub release: f32, +} + +/// Filter spec with optional cutoff automation. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct FilterSpec { + #[serde(rename = "type")] + pub kind: FilterKind, + #[serde(default = "default_cutoff")] + pub cutoff: f32, + /// Optional cutoff sweep. If present, `cutoff` is the start value. + #[serde(default)] + pub cutoff_sweep: Option, + #[serde(default = "default_q")] + pub q: f32, +} + +/// Cutoff frequency automation (start → end over the sound's duration). +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct CutoffAutomation { + pub start: f32, + pub end: f32, + #[serde(default = "default_curve")] + pub curve: soundgen_core::SweepCurve, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FilterKind { + Lowpass, + Highpass, +} + +/// Channel specification — discriminated by `type` field. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ChannelSpec { + Pulse { + /// Duty cycle percent (12, 25, 50, 75). + #[serde(default = "default_duty")] + duty: u8, + #[serde(default)] + frequency: FrequencyAutomation, + #[serde(default)] + envelope: Option, + #[serde(default)] + filter: Option, + #[serde(default = "default_volume")] + volume: f32, + #[serde(default)] + pan: f32, + }, + Triangle { + #[serde(default)] + frequency: FrequencyAutomation, + #[serde(default)] + envelope: Option, + #[serde(default)] + filter: Option, + #[serde(default = "default_volume")] + volume: f32, + #[serde(default)] + pan: f32, + }, + Noise { + /// "white" or "periodic". + #[serde(default = "default_noise_mode")] + mode: String, + /// Base frequency for the noise clock. + #[serde(default = "default_noise_freq")] + frequency: f32, + #[serde(default)] + envelope: Option, + #[serde(default)] + filter: Option, + #[serde(default = "default_volume")] + volume: f32, + #[serde(default)] + pan: f32, + }, +} + +// Defaults + +fn default_sample_rate() -> u32 { + 44100 +} +fn default_sustain() -> f32 { + 0.7 +} +fn default_cutoff() -> f32 { + 5000.0 +} +fn default_q() -> f32 { + 0.707 +} +fn default_curve() -> soundgen_core::SweepCurve { + soundgen_core::SweepCurve::Linear +} +fn default_duty() -> u8 { + 50 +} +fn default_volume() -> f32 { + 0.7 +} +fn default_noise_mode() -> String { + "white".to_string() +} +fn default_noise_freq() -> f32 { + 8000.0 +} + +impl Default for EnvelopeSpec { + fn default() -> Self { + Self { + attack: 0.01, + decay: 0.1, + sustain: 0.0, + release: 0.1, + } + } +} + +impl Default for SoundSpec { + fn default() -> Self { + Self { + name: String::new(), + duration: 0.2, + sample_rate: 44100, + channels: vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pulse_channel() { + let json = r#"{ + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 800, "curve": "exponential" }, + "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, + "volume": 0.7 + }"#; + let ch: ChannelSpec = serde_json::from_str(json).unwrap(); + match ch { + ChannelSpec::Pulse { duty, volume, .. } => { + assert_eq!(duty, 50); + assert!((volume - 0.7).abs() < 0.01); + } + _ => panic!("expected Pulse"), + } + } + + #[test] + fn test_parse_noise_channel() { + let json = r#"{ + "type": "noise", + "mode": "white", + "filter": { "type": "lowpass", "cutoff": 2000, "cutoff_sweep": { "start": 2000, "end": 200, "curve": "exponential" } }, + "envelope": { "attack": 0.005, "decay": 0.7, "sustain": 0.0, "release": 0.095 }, + "volume": 0.9 + }"#; + let ch: ChannelSpec = serde_json::from_str(json).unwrap(); + match ch { + ChannelSpec::Noise { mode, filter, .. } => { + assert_eq!(mode, "white"); + assert!(filter.is_some()); + } + _ => panic!("expected Noise"), + } + } + + #[test] + fn test_parse_full_spec() { + let json = r#"{ + "name": "jump", + "duration": 0.3, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 800, "curve": "exponential" }, + "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, + "volume": 0.7 + } + ] + }"#; + let spec: SoundSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.name, "jump"); + assert!((spec.duration - 0.3).abs() < 0.001); + assert_eq!(spec.channels.len(), 1); + } + + #[test] + fn test_parse_with_defaults() { + let json = r#"{ + "name": "test", + "duration": 0.1, + "channels": [ + { "type": "triangle", "frequency": { "start": 220, "end": 220 } } + ] + }"#; + let spec: SoundSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.sample_rate, 44100); // default + match &spec.channels[0] { + ChannelSpec::Triangle { volume, .. } => { + assert!((volume - 0.7).abs() < 0.01); // default + } + _ => panic!("expected Triangle"), + } + } + + #[test] + fn test_spec_serialization_roundtrip() { + let spec = SoundSpec { + name: "test".to_string(), + duration: 0.5, + sample_rate: 48000, + channels: vec![ChannelSpec::Pulse { + duty: 25, + frequency: FrequencyAutomation::fixed(440.0), + envelope: Some(EnvelopeSpec { + attack: 0.01, + decay: 0.1, + sustain: 0.5, + release: 0.2, + }), + filter: None, + volume: 0.8, + pan: -0.5, + }], + }; + let json = serde_json::to_string_pretty(&spec).unwrap(); + let spec2: SoundSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(spec2.name, spec.name); + assert!((spec2.duration - spec.duration).abs() < 0.001); + } +} diff --git a/crates/soundgen-fmt/src/preset.rs b/crates/soundgen-fmt/src/preset.rs new file mode 100644 index 0000000..2a7e19e --- /dev/null +++ b/crates/soundgen-fmt/src/preset.rs @@ -0,0 +1,226 @@ +//! Preset registry — loads and manages sound presets from JSON files. +//! +//! Presets are JSON data files in `presets/{sfx,ui,ambient}/`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::SoundSpec; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PresetCategory { + Sfx, + Ui, + Ambient, +} + +impl PresetCategory { + pub fn as_str(&self) -> &'static str { + match self { + PresetCategory::Sfx => "sfx", + PresetCategory::Ui => "ui", + PresetCategory::Ambient => "ambient", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "sfx" => Some(PresetCategory::Sfx), + "ui" => Some(PresetCategory::Ui), + "ambient" => Some(PresetCategory::Ambient), + _ => None, + } + } +} + +/// A loaded preset with its category and source path. +#[derive(Clone, Debug)] +pub struct PresetEntry { + pub name: String, + pub category: PresetCategory, + pub spec: SoundSpec, + pub source: PathBuf, +} + +pub struct PresetRegistry { + presets: Vec, +} + +impl PresetRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { presets: vec![] } + } + + /// Load all presets from a directory tree. + /// + /// Expected layout: `base/{sfx,ui,ambient}/*.json` + pub fn load_dir(base: &Path) -> Result { + let mut registry = Self::new(); + + for category in [ + PresetCategory::Sfx, + PresetCategory::Ui, + PresetCategory::Ambient, + ] { + let dir = base.join(category.as_str()); + if !dir.exists() { + continue; + } + registry.load_category_dir(&dir, category)?; + } + + Ok(registry) + } + + fn load_category_dir(&mut self, dir: &Path, category: PresetCategory) -> Result<(), String> { + let entries = + fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("dir entry: {}", e))?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + self.load_file(&path, category)?; + } + } + Ok(()) + } + + fn load_file(&mut self, path: &Path, category: PresetCategory) -> Result<(), String> { + let content = + fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?; + let spec: SoundSpec = serde_json::from_str(&content) + .map_err(|e| format!("parse {}: {}", path.display(), e))?; + + self.presets.push(PresetEntry { + name: spec.name.clone(), + category, + spec, + source: path.to_path_buf(), + }); + Ok(()) + } + + /// Register a single spec by name and category. + pub fn register(&mut self, name: &str, category: PresetCategory, spec: SoundSpec) { + self.presets.push(PresetEntry { + name: name.to_string(), + category, + spec, + source: PathBuf::new(), + }); + } + + /// Find a preset by name (case-insensitive). + pub fn get(&self, name: &str) -> Option<&PresetEntry> { + self.presets + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + } + + /// Find a preset by name and category. + pub fn get_in_category(&self, name: &str, category: PresetCategory) -> Option<&PresetEntry> { + self.presets + .iter() + .find(|p| p.category == category && p.name.eq_ignore_ascii_case(name)) + } + + /// List all presets, optionally filtered by category. + pub fn list(&self, category: Option) -> Vec<&PresetEntry> { + self.presets + .iter() + .filter(|p| category.map_or(true, |c| p.category == c)) + .collect() + } + + /// List preset names. + pub fn names(&self) -> Vec { + self.presets.iter().map(|p| p.name.clone()).collect() + } + + pub fn len(&self) -> usize { + self.presets.len() + } + + pub fn is_empty(&self) -> bool { + self.presets.is_empty() + } +} + +impl Default for PresetRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ChannelSpec; + use soundgen_core::FrequencyAutomation; + + #[test] + fn test_register_and_get() { + let mut reg = PresetRegistry::new(); + let spec = SoundSpec { + name: "test_sound".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + reg.register("test_sound", PresetCategory::Sfx, spec); + + assert!(reg.get("test_sound").is_some()); + assert!(reg.get("TEST_SOUND").is_some()); // case-insensitive + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn test_list_by_category() { + let mut reg = PresetRegistry::new(); + let make_spec = |name: &str| SoundSpec { + name: name.to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![], + }; + reg.register("a", PresetCategory::Sfx, make_spec("a")); + reg.register("b", PresetCategory::Sfx, make_spec("b")); + reg.register("c", PresetCategory::Ui, make_spec("c")); + + assert_eq!(reg.list(None).len(), 3); + assert_eq!(reg.list(Some(PresetCategory::Sfx)).len(), 2); + assert_eq!(reg.list(Some(PresetCategory::Ui)).len(), 1); + assert_eq!(reg.list(Some(PresetCategory::Ambient)).len(), 0); + } + + #[test] + fn test_load_dir() { + let tmp = std::env::temp_dir().join("soundgen_test_presets"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(tmp.join("sfx")).unwrap(); + + let spec_json = r#"{ + "name": "test_jump", + "duration": 0.2, + "channels": [ + { "type": "pulse", "duty": 50, "frequency": { "start": 200, "end": 600 } } + ] + }"#; + std::fs::write(tmp.join("sfx").join("jump.json"), spec_json).unwrap(); + + let reg = PresetRegistry::load_dir(&tmp).unwrap(); + assert_eq!(reg.len(), 1); + assert!(reg.get("test_jump").is_some()); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/crates/soundgen-fmt/src/renderer.rs b/crates/soundgen-fmt/src/renderer.rs new file mode 100644 index 0000000..a73af0d --- /dev/null +++ b/crates/soundgen-fmt/src/renderer.rs @@ -0,0 +1,265 @@ +//! Render a [`SoundSpec`] to interleaved stereo `Vec`. +use soundgen_core::{ + ChannelRenderer, Envelope, Filter, FilterType, FrequencyAutomation, NoiseMode, Sweep, VoiceKind, +}; +use VoiceKind as VK; + +use crate::{ChannelSpec, EnvelopeSpec, FilterSpec, SoundSpec}; + +/// Render a [`SoundSpec`] to interleaved stereo samples (L, R, L, R, ...). +pub fn render_spec(spec: &SoundSpec) -> Vec { + let sr = spec.sample_rate as f32; + let n_samples = (spec.duration * sr).ceil() as usize; + let mut channels: Vec = spec + .channels + .iter() + .map(|ch| build_channel(ch, sr, spec.duration)) + .collect(); + + for ch in &mut channels { + ch.trigger(); + } + + soundgen_core::render_channels(&mut channels, n_samples) +} + +fn build_channel(spec: &ChannelSpec, sr: f32, duration: f32) -> ChannelRenderer { + match spec { + ChannelSpec::Pulse { + duty, + frequency, + envelope, + filter, + volume, + pan, + } => { + let duty_cycle = soundgen_core::voice::DutyCycle::from_percent(*duty); + let voice = VK::Pulse(soundgen_core::voice::PulseChannel::new(sr, duty_cycle)); + build_renderer( + voice, sr, duration, frequency, envelope, filter, *volume, *pan, + ) + } + ChannelSpec::Triangle { + frequency, + envelope, + filter, + volume, + pan, + } => { + let voice = VK::Triangle(soundgen_core::voice::TriangleChannel::new(sr)); + build_renderer( + voice, sr, duration, frequency, envelope, filter, *volume, *pan, + ) + } + ChannelSpec::Noise { + mode, + frequency, + envelope, + filter, + volume, + pan, + } => { + let noise_mode = match mode.as_str() { + "periodic" => NoiseMode::Periodic, + _ => NoiseMode::White, + }; + let noise = soundgen_core::voice::NoiseChannel::new(sr, noise_mode); + let voice = VK::Noise(noise); + // Noise "frequency" is a static clock rate — wrap as fixed automation. + let freq_auto = FrequencyAutomation::fixed(*frequency); + build_renderer( + voice, sr, duration, &freq_auto, envelope, filter, *volume, *pan, + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn build_renderer( + voice: VoiceKind, + sr: f32, + duration: f32, + frequency: &FrequencyAutomation, + envelope: &Option, + filter: &Option, + volume: f32, + pan: f32, +) -> ChannelRenderer { + let mut cr = ChannelRenderer::new(voice, sr) + .with_volume(volume) + .with_pan(pan) + .with_initial_frequency(frequency.start); + + // Frequency sweep (only if frequency changes over time) + if !frequency.is_static() { + cr = cr.with_freq_sweep(frequency.to_sweep(sr, duration)); + } + + // Envelope + if let Some(env) = envelope { + cr = cr.with_envelope(Envelope::adsr( + sr, + env.attack, + env.decay, + env.sustain, + env.release, + )); + } + + // Filter + if let Some(filt) = filter { + let ft = match filt.kind { + crate::FilterKind::Lowpass => FilterType::Lowpass, + crate::FilterKind::Highpass => FilterType::Highpass, + }; + let cutoff_start = filt + .cutoff_sweep + .as_ref() + .map(|s| s.start) + .unwrap_or(filt.cutoff); + let filter = Filter::new(sr, ft, cutoff_start, filt.q); + cr = cr.with_filter(filter); + + // Filter cutoff sweep + if let Some(cs) = &filt.cutoff_sweep { + let sweep = Sweep::new(sr, cs.start, cs.end, cs.curve, duration); + cr = cr.with_filter_sweep(sweep); + } + } + + cr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_simple_spec() { + let spec = SoundSpec { + name: "test".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.0, + sustain: 1.0, + release: 0.0, + }), + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + let out = render_spec(&spec); + // Duration 0.1s * 44100 = 4410 samples → 8820 interleaved + assert_eq!(out.len(), 8820); + // Should have non-zero samples + let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count(); + assert!(non_zero > 100, "expected non-zero output"); + } + + #[test] + fn test_render_noise_spec() { + let spec = SoundSpec { + name: "noise_test".to_string(), + duration: 0.05, + sample_rate: 44100, + channels: vec![ChannelSpec::Noise { + mode: "white".to_string(), + frequency: 8000.0, + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.05, + sustain: 0.0, + release: 0.0, + }), + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + let out = render_spec(&spec); + assert_eq!(out.len(), 4410); + // Noise should produce some non-zero samples + let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count(); + assert!(non_zero > 0); + } + + #[test] + fn test_render_empty_spec() { + let spec = SoundSpec { + name: "empty".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![], + }; + let out = render_spec(&spec); + assert_eq!(out.len(), 8820); + // All zeros (no channels) + assert!(out.iter().all(|&s| s == 0.0)); + } + + #[test] + fn test_render_with_frequency_sweep() { + let spec = SoundSpec { + name: "sweep".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation { + start: 100.0, + end: 1000.0, + curve: soundgen_core::SweepCurve::Exponential, + }, + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.0, + sustain: 1.0, + release: 0.0, + }), + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + let out = render_spec(&spec); + assert_eq!(out.len(), 8820); + // Should produce output + let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count(); + assert!(non_zero > 100); + } + + #[test] + fn test_render_with_filter() { + let spec = SoundSpec { + name: "filtered".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Noise { + mode: "white".to_string(), + frequency: 8000.0, + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.0, + sustain: 1.0, + release: 0.0, + }), + filter: Some(FilterSpec { + kind: crate::FilterKind::Lowpass, + cutoff: 500.0, + cutoff_sweep: None, + q: 0.707, + }), + volume: 0.5, + pan: 0.0, + }], + }; + let out = render_spec(&spec); + assert_eq!(out.len(), 8820); + } +} diff --git a/crates/soundgen-fmt/tests/integration.rs b/crates/soundgen-fmt/tests/integration.rs new file mode 100644 index 0000000..35d08b4 --- /dev/null +++ b/crates/soundgen-fmt/tests/integration.rs @@ -0,0 +1,236 @@ +//! Integration tests: end-to-end render → WAV verification. + +use hound; +use soundgen_core::{FrequencyAutomation, SweepCurve}; +use soundgen_fmt::{ + render_spec, ChannelSpec, CutoffAutomation, EnvelopeSpec, FilterKind, FilterSpec, + PresetCategory, PresetRegistry, SoundSpec, +}; +use soundgen_io::write_wav; + +/// Get the workspace root path (presets/ directory). +fn workspace_root() -> &'static str { + concat!(env!("CARGO_MANIFEST_DIR"), "/../..") +} + +fn rms(samples: &[f32]) -> f32 { + if samples.is_empty() { + return 0.0; + } + let sum: f32 = samples.iter().map(|s| s * s).sum(); + (sum / samples.len() as f32).sqrt() +} + +#[test] +fn test_render_spec_to_wav_and_verify() { + let spec = SoundSpec { + name: "integration_test".to_string(), + duration: 0.2, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation { + start: 200.0, + end: 800.0, + curve: SweepCurve::Exponential, + }, + envelope: Some(EnvelopeSpec { + attack: 0.005, + decay: 0.05, + sustain: 0.3, + release: 0.1, + }), + filter: None, + volume: 0.6, + pan: 0.0, + }], + }; + + let samples = render_spec(&spec); + // 0.2s * 44100 = 8820 frames -> 17640 interleaved stereo samples + assert_eq!(samples.len(), 17640); + + let mono: Vec = samples.iter().step_by(2).cloned().collect(); + let r = rms(&mono); + assert!(r > 0.01, "RMS too low: {}", r); + + let path = std::env::temp_dir().join("soundgen_integration.wav"); + write_wav(&path, &samples, 44100).unwrap(); + assert!(path.exists()); + + let reader = hound::WavReader::open(&path).unwrap(); + let wav_spec = reader.spec(); + assert_eq!(wav_spec.channels, 2); + assert_eq!(wav_spec.sample_rate, 44100); + + let _ = std::fs::remove_file(&path); +} + +#[test] +fn test_all_presets_render_successfully() { + let presets_dir = std::path::Path::new(workspace_root()).join("presets"); + let registry = PresetRegistry::load_dir(&presets_dir).expect("failed to load presets"); + + assert!( + registry.len() >= 10, + "expected at least 10 presets, got {}", + registry.len() + ); + + for entry in registry.list(None) { + let samples = render_spec(&entry.spec); + let expected_len = + (entry.spec.duration * entry.spec.sample_rate as f32).ceil() as usize * 2; + assert_eq!( + samples.len(), + expected_len, + "preset '{}' has wrong sample count", + entry.name + ); + + let mono: Vec = samples.iter().step_by(2).cloned().collect(); + let r = rms(&mono); + assert!( + r > 0.001, + "preset '{}' has near-zero RMS: {}", + entry.name, + r + ); + } +} + +#[test] +fn test_sfx_and_ui_categories_present() { + let presets_dir = std::path::Path::new(workspace_root()).join("presets"); + let registry = PresetRegistry::load_dir(&presets_dir).unwrap(); + + let sfx = registry.list(Some(PresetCategory::Sfx)); + let ui = registry.list(Some(PresetCategory::Ui)); + + assert!(sfx.len() >= 6, "expected at least 6 SFX presets"); + assert!(ui.len() >= 4, "expected at least 4 UI presets"); + + assert!(registry.get("jump").is_some()); + assert!(registry.get("explosion").is_some()); + assert!(registry.get("click").is_some()); + assert!(registry.get("confirm").is_some()); +} + +#[test] +fn test_json_spec_roundtrip() { + let spec = SoundSpec { + name: "roundtrip".to_string(), + duration: 0.3, + sample_rate: 48000, + channels: vec![ChannelSpec::Noise { + mode: "white".to_string(), + frequency: 8000.0, + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.2, + sustain: 0.0, + release: 0.1, + }), + filter: Some(FilterSpec { + kind: FilterKind::Lowpass, + cutoff: 2000.0, + cutoff_sweep: Some(CutoffAutomation { + start: 2000.0, + end: 200.0, + curve: SweepCurve::Exponential, + }), + q: 0.707, + }), + volume: 0.7, + pan: 0.0, + }], + }; + + let json = serde_json::to_string_pretty(&spec).unwrap(); + let spec2: SoundSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(spec2.name, spec.name); + assert!((spec2.duration - spec.duration).abs() < 0.001); + assert_eq!(spec2.sample_rate, spec.sample_rate); + assert_eq!(spec2.channels.len(), spec.channels.len()); +} + +#[test] +fn test_wav_file_format_correct() { + let spec = SoundSpec { + name: "wav_format_test".to_string(), + duration: 0.05, + sample_rate: 22050, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: Some(EnvelopeSpec { + attack: 0.0, + decay: 0.0, + sustain: 1.0, + release: 0.0, + }), + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + + let samples = render_spec(&spec); + let path = std::env::temp_dir().join("soundgen_wav_format_test.wav"); + write_wav(&path, &samples, 22050).unwrap(); + + let reader = hound::WavReader::open(&path).unwrap(); + let wav_spec = reader.spec(); + assert_eq!(wav_spec.channels, 2); + assert_eq!(wav_spec.sample_rate, 22050); + assert_eq!(wav_spec.bits_per_sample, 16); + + // 0.05 * 22050 = 1102.5, ceil = 1103 frames, * 2 channels = 2206 samples + let sample_count = reader.into_samples::().count(); + assert_eq!(sample_count, 2206); + + let _ = std::fs::remove_file(&path); +} + +#[test] +fn test_multi_channel_render() { + let spec = SoundSpec { + name: "multi".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ + ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: Some(EnvelopeSpec::default()), + filter: None, + volume: 0.5, + pan: -0.5, + }, + ChannelSpec::Triangle { + frequency: FrequencyAutomation::fixed(220.0), + envelope: Some(EnvelopeSpec::default()), + filter: None, + volume: 0.4, + pan: 0.5, + }, + ChannelSpec::Noise { + mode: "white".to_string(), + frequency: 5000.0, + envelope: Some(EnvelopeSpec::default()), + filter: None, + volume: 0.3, + pan: 0.0, + }, + ], + }; + + let samples = render_spec(&spec); + // 0.1 * 44100 = 4410 frames -> 8820 interleaved + assert_eq!(samples.len(), 8820); + + let left: Vec = samples.iter().step_by(2).cloned().collect(); + let right: Vec = samples.iter().skip(1).step_by(2).cloned().collect(); + assert!(rms(&left) > 0.01); + assert!(rms(&right) > 0.01); +} diff --git a/crates/soundgen-gui/Cargo.toml b/crates/soundgen-gui/Cargo.toml new file mode 100644 index 0000000..9a32e2d --- /dev/null +++ b/crates/soundgen-gui/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "soundgen-gui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "soundgen-gui" +path = "src/main.rs" + +[dependencies] +soundgen-core.workspace = true +soundgen-fmt.workspace = true +soundgen-io.workspace = true +soundgen-seq.workspace = true +eframe.workspace = true +egui.workspace = true +egui-file-dialog.workspace = true +serde_json.workspace = true diff --git a/crates/soundgen-gui/src/app.rs b/crates/soundgen-gui/src/app.rs new file mode 100644 index 0000000..faecca3 --- /dev/null +++ b/crates/soundgen-gui/src/app.rs @@ -0,0 +1,912 @@ +//! Main Soundgen GUI application. +//! +//! Layout: +//! - Top: menu bar (File / Edit) +//! - Below: tab bar (SFX Editor / Sequencer) + transport (Play/Stop) +//! - Left: preset browser +//! - Center: editor content +//! - Bottom: status bar + virtual keyboard + +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use eframe::egui; +use egui_file_dialog::FileDialog; +use soundgen_fmt::{render_spec, ChannelSpec, PresetRegistry, SoundSpec}; +use soundgen_io::{play, write_wav}; +use soundgen_seq::{render_song, Song}; + +use crate::channel_panel::channel_panel; +use crate::keyboard::Keyboard; +use crate::preset_browser::PresetBrowser; +use crate::waveform::waveform_display; + +// ── Undo/Redo ──────────────────────────────────────────── + +struct UndoStack { + undo: Vec, + redo: Vec, + last_save: Instant, +} + +impl UndoStack { + fn new() -> Self { + Self { + undo: vec![], + redo: vec![], + last_save: Instant::now(), + } + } + + fn push(&mut self, spec: &SoundSpec) { + // Debounce: don't push more often than every 500ms + if self.last_save.elapsed() < Duration::from_millis(500) { + if let Some(last) = self.undo.last_mut() { + *last = spec.clone(); + return; + } + } + self.last_save = Instant::now(); + self.undo.push(spec.clone()); + self.redo.clear(); + if self.undo.len() > 50 { + self.undo.remove(0); + } + } + + fn undo(&mut self, current: &SoundSpec) -> Option { + if let Some(prev) = self.undo.pop() { + self.redo.push(current.clone()); + Some(prev) + } else { + None + } + } + + fn redo(&mut self, current: &SoundSpec) -> Option { + if let Some(next) = self.redo.pop() { + self.undo.push(current.clone()); + Some(next) + } else { + None + } + } + + fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + fn can_redo(&self) -> bool { + !self.redo.is_empty() + } +} + +// ── App State ──────────────────────────────────────────── + +pub struct SoundgenApp { + spec: SoundSpec, + registry: PresetRegistry, + browser: PresetBrowser, + keyboard: Keyboard, + song: Song, + song_json: String, + song_json_error: Option, + preview_samples: Vec, + preview_dirty: bool, + last_preview_update: Option, + status: String, + status_time: Option, + playing: Option>>, + tab: Tab, + file_path: String, + undo: UndoStack, + file_dialog: FileDialog, + export_dialog: FileDialog, + open_dialog: FileDialog, + last_keyboard_freq: f32, + last_keyboard_vel: u8, +} + +#[derive(PartialEq)] +enum Tab { + SfxEditor, + Sequencer, +} + +impl Default for SoundgenApp { + fn default() -> Self { + let registry = + PresetRegistry::load_dir(std::path::Path::new("presets")).unwrap_or_else(|e| { + eprintln!("Warning: could not load presets: {}", e); + PresetRegistry::new() + }); + + let spec = registry + .get("jump") + .map(|e| e.spec.clone()) + .unwrap_or(SoundSpec { + name: "new_sound".to_string(), + duration: 0.2, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: soundgen_core::FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }], + }); + + let song = default_song(); + let song_json = serde_json::to_string_pretty(&song).unwrap_or_default(); + + Self { + spec, + registry, + browser: PresetBrowser::new(), + keyboard: Keyboard::new(60, 2), + song, + song_json, + song_json_error: None, + preview_samples: vec![], + preview_dirty: true, + last_preview_update: None, + status: "Ready".to_string(), + status_time: None, + playing: None, + tab: Tab::SfxEditor, + file_path: "sound.json".to_string(), + undo: UndoStack::new(), + file_dialog: FileDialog::default(), + export_dialog: FileDialog::default(), + open_dialog: FileDialog::default(), + last_keyboard_freq: 440.0, + last_keyboard_vel: 100, + } + } +} + +fn default_song() -> Song { + use soundgen_seq::{EnvelopeConfig, Note, Pattern, Row, TrackConfig, TrackVoice}; + Song { + bpm: 120.0, + rows_per_beat: 4, + sample_rate: 44100, + tracks: vec![ + TrackConfig { + voice: TrackVoice::Pulse { duty: 50 }, + envelope: Some(EnvelopeConfig { + attack: 0.005, + decay: 0.03, + sustain: 0.6, + release: 0.05, + }), + volume: 0.35, + pan: -0.2, + }, + TrackConfig { + voice: TrackVoice::Triangle, + envelope: Some(EnvelopeConfig { + attack: 0.01, + decay: 0.05, + sustain: 0.5, + release: 0.08, + }), + volume: 0.4, + pan: 0.3, + }, + ], + patterns: vec![Pattern { + rows: vec![ + Row { + notes: vec![ + Some(Note::from_name("C4", 1.0).unwrap()), + Some(Note::from_name("C2", 1.0).unwrap()), + ], + }, + Row { + notes: vec![None, None], + }, + Row { + notes: vec![Some(Note::from_name("E4", 1.0).unwrap()), None], + }, + Row { + notes: vec![None, None], + }, + Row { + notes: vec![ + Some(Note::from_name("G4", 1.0).unwrap()), + Some(Note::from_name("G2", 1.0).unwrap()), + ], + }, + Row { + notes: vec![None, None], + }, + Row { + notes: vec![Some(Note::from_name("E4", 1.0).unwrap()), None], + }, + Row { + notes: vec![None, None], + }, + ], + }], + pattern_order: vec![0, 0], + } +} + +impl eframe::App for SoundgenApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Handle hotkeys + self.handle_hotkeys(ctx); + + // Debounced preview update + if self.preview_dirty { + if let Some(last) = self.last_preview_update { + if last.elapsed() >= Duration::from_millis(100) { + self.update_preview(); + self.preview_dirty = false; + self.last_preview_update = None; + } else { + ctx.request_repaint_after(Duration::from_millis(50)); + } + } else { + self.last_preview_update = Some(Instant::now()); + ctx.request_repaint_after(Duration::from_millis(100)); + } + } + + // Menu bar + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + ui.menu_button("File", |ui| { + if ui.button("Open... (Ctrl+O)").clicked() { + self.open_dialog.select_file(); + ui.close_menu(); + } + if ui.button("Save... (Ctrl+S)").clicked() { + self.file_dialog.save_file(); + ui.close_menu(); + } + if ui.button("Export WAV... (Ctrl+E)").clicked() { + self.export_dialog.save_file(); + ui.close_menu(); + } + if ui.button("New Sound").clicked() { + self.new_sound(); + ui.close_menu(); + } + }); + + ui.menu_button("Edit", |ui| { + ui.add_enabled_ui(self.undo.can_undo(), |ui| { + if ui.button("Undo (Ctrl+Z)").clicked() { + self.do_undo(); + ui.close_menu(); + } + }); + ui.add_enabled_ui(self.undo.can_redo(), |ui| { + if ui.button("Redo (Ctrl+Shift+Z)").clicked() { + self.do_redo(); + ui.close_menu(); + } + }); + }); + + ui.menu_button("Add Channel", |ui| { + if ui.button("Pulse").clicked() { + self.add_channel(ChannelType::Pulse); + ui.close_menu(); + } + if ui.button("Triangle").clicked() { + self.add_channel(ChannelType::Triangle); + ui.close_menu(); + } + if ui.button("Noise").clicked() { + self.add_channel(ChannelType::Noise); + ui.close_menu(); + } + }); + + ui.separator(); + + ui.selectable_value(&mut self.tab, Tab::SfxEditor, "SFX Editor"); + ui.selectable_value(&mut self.tab, Tab::Sequencer, "Sequencer"); + + ui.separator(); + + // Transport + let play_label = if self.playing.is_some() { + "⏸ Stop" + } else { + "▶ Play" + }; + if ui.button(play_label).clicked() { + if self.playing.is_some() { + self.stop_playback(); + self.set_status("Stopped".to_string()); + } else { + self.play_current(); + } + } + }); + }); + + // Status bar (above keyboard) + egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + let status_color = if self.status.starts_with("Error") { + egui::Color32::from_rgb(255, 100, 100) + } else { + egui::Color32::from_rgb(120, 180, 120) + }; + ui.label(egui::RichText::new(&self.status).color(status_color)); + ui.separator(); + ui.label( + egui::RichText::new(format!("{} Hz", self.spec.sample_rate)) + .small() + .weak(), + ); + if self.last_keyboard_freq > 0.0 { + ui.label( + egui::RichText::new(format!("Note: {:.0} Hz", self.last_keyboard_freq)) + .small() + .weak(), + ); + } + ui.separator(); + ui.label( + egui::RichText::new(format!("Channels: {}", self.spec.channels.len())) + .small() + .weak(), + ); + if self.undo.can_undo() { + ui.label( + egui::RichText::new(format!("Undo: {}", self.undo.undo.len())) + .small() + .weak(), + ); + } + }); + }); + + // Keyboard panel + egui::TopBottomPanel::bottom("keyboard_panel") + .resizable(true) + .default_height(140.0) + .show(ctx, |ui| { + let (presses, _releases, vel) = self.keyboard.show(ui); + + for midi in &presses { + let freq = crate::keyboard::midi_to_freq(*midi); + self.last_keyboard_freq = freq; + self.last_keyboard_vel = vel; + // Set frequency on first channel + if !self.spec.channels.is_empty() { + self.spec.channels[0] = + set_channel_freq(self.spec.channels[0].clone(), freq); + // Set velocity as volume + self.spec.channels[0] = + set_channel_vol(self.spec.channels[0].clone(), vel as f32 / 127.0); + } + } + + if !presses.is_empty() { + self.play_current(); + } + }); + + // Left panel: preset browser + egui::SidePanel::left("browser") + .resizable(true) + .default_width(260.0) + .show(ctx, |ui| { + let to_play = self.browser.show(ui, &self.registry); + if let Some(name) = to_play { + if let Some(entry) = self.registry.get(&name) { + self.undo.push(&self.spec); + self.spec = entry.spec.clone(); + self.preview_dirty = true; + self.status = format!("Loaded: {}", name); + self.play_current(); + } + } + + ui.separator(); + + let selected_name = self.browser.selected.clone(); + if let Some(name) = &selected_name { + let entry = self.registry.get(name).cloned(); + if let Some(entry) = entry { + let spec = entry.spec.clone(); + let display_name = entry.name.clone(); + let dur = entry.spec.duration; + let nch = entry.spec.channels.len(); + let sr = entry.spec.sample_rate; + ui.group(|ui| { + ui.heading(display_name); + ui.label(format!("Duration: {:.2}s", dur)); + ui.label(format!("Channels: {}", nch)); + if ui.button("Load into editor").clicked() { + self.undo.push(&self.spec); + self.spec = spec.clone(); + self.preview_dirty = true; + self.status = format!("Loaded: {}", name); + } + if ui.button("▶ Play preset").clicked() { + let samples = render_spec(&spec); + self.start_playback(samples, sr); + } + }); + } + } + }); + + // Central panel: editor + egui::CentralPanel::default().show(ctx, |ui| match self.tab { + Tab::SfxEditor => self.show_sfx_editor(ui), + Tab::Sequencer => self.show_sequencer(ui), + }); + + // Handle file dialogs + self.handle_file_dialogs(ctx); + } +} + +#[derive(Clone, Copy)] +enum ChannelType { + Pulse, + Triangle, + Noise, +} + +impl SoundgenApp { + fn handle_hotkeys(&mut self, ctx: &egui::Context) { + let modifiers = ctx.input(|i| i.modifiers); + let ctrl = modifiers.ctrl || modifiers.command; + + // Space: play/stop + if ctx.input(|i| i.key_pressed(egui::Key::Space)) { + if self.playing.is_some() { + self.stop_playback(); + self.set_status("Stopped".to_string()); + } else { + self.play_current(); + } + } + + // Ctrl+S: save + if ctrl && ctx.input(|i| i.key_pressed(egui::Key::S)) { + self.file_dialog.save_file(); + } + + // Ctrl+O: open + if ctrl && ctx.input(|i| i.key_pressed(egui::Key::O)) { + self.open_dialog.select_file(); + } + + // Ctrl+E: export WAV + if ctrl && ctx.input(|i| i.key_pressed(egui::Key::E)) { + self.export_dialog.save_file(); + } + + // Ctrl+Z: undo, Ctrl+Shift+Z: redo + if ctrl && ctx.input(|i| i.key_pressed(egui::Key::Z)) { + if modifiers.shift { + self.do_redo(); + } else { + self.do_undo(); + } + } + } + + fn handle_file_dialogs(&mut self, ctx: &egui::Context) { + // Save dialog + self.file_dialog.update(ctx); + if let Some(path) = self.file_dialog.take_selected() { + let json = serde_json::to_string_pretty(&self.spec).unwrap_or_default(); + match std::fs::write(&path, json) { + Ok(()) => { + self.file_path = path.display().to_string(); + self.set_status(format!("Saved to {}", path.display())); + } + Err(e) => self.set_status(format!("Save error: {}", e)), + } + } + + // Open dialog + self.open_dialog.update(ctx); + if let Some(path) = self.open_dialog.take_selected() { + match std::fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(spec) => { + self.undo.push(&self.spec); + self.spec = spec; + self.file_path = path.display().to_string(); + self.preview_dirty = true; + self.set_status(format!("Loaded from {}", path.display())); + } + Err(e) => self.set_status(format!("Parse error: {}", e)), + }, + Err(e) => self.set_status(format!("Load error: {}", e)), + } + } + + // Export dialog + self.export_dialog.update(ctx); + if let Some(path) = self.export_dialog.take_selected() { + let samples = render_spec(&self.spec); + match write_wav(&path, &samples, self.spec.sample_rate) { + Ok(()) => self.set_status(format!("Exported to {}", path.display())), + Err(e) => self.set_status(format!("Export error: {}", e)), + } + } + } + + fn show_sfx_editor(&mut self, ui: &mut egui::Ui) { + // Sound properties + ui.horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut self.spec.name); + ui.separator(); + ui.label("Duration:"); + if ui + .add( + egui::Slider::new(&mut self.spec.duration, 0.01..=10.0) + .suffix("s") + .fixed_decimals(2), + ) + .changed() + { + self.mark_dirty(); + } + }); + + ui.separator(); + + // Channel controls + let total = self.spec.channels.len(); + let mut to_remove: Option = None; + let mut to_dup: Option = None; + let mut to_move_up: Option = None; + let mut to_move_down: Option = None; + let mut any_changed = false; + + for (i, channel) in self.spec.channels.iter_mut().enumerate() { + let edit = channel_panel(ui, channel, i, total, "ch"); + if edit.changed { + any_changed = true; + } + if edit.deleted { + to_remove = Some(i); + } + if edit.duplicated { + to_dup = Some(i); + } + if edit.moved_up { + to_move_up = Some(i); + } + if edit.moved_down { + to_move_down = Some(i); + } + } + + if any_changed { + self.mark_dirty(); + } + + // Process channel edits + if let Some(idx) = to_remove { + self.undo.push(&self.spec); + self.spec.channels.remove(idx); + self.mark_dirty(); + } + if let Some(idx) = to_dup { + self.undo.push(&self.spec); + let dup = self.spec.channels[idx].clone(); + // Adjust name if possible + self.spec.channels.insert(idx + 1, dup); + self.mark_dirty(); + } + if let Some(idx) = to_move_up { + if idx > 0 { + self.undo.push(&self.spec); + self.spec.channels.swap(idx, idx - 1); + self.mark_dirty(); + } + } + if let Some(idx) = to_move_down { + if idx < total - 1 { + self.undo.push(&self.spec); + self.spec.channels.swap(idx, idx + 1); + self.mark_dirty(); + } + } + + ui.separator(); + + // Waveform preview + ui.heading("Preview"); + let preview_dur = self.spec.duration; + waveform_display(ui, &self.preview_samples, preview_dur, 80.0); + + ui.horizontal(|ui| { + if ui.button("▶ Play").on_hover_text("Space").clicked() { + self.play_current(); + } + if ui + .button("⟳ Refresh") + .on_hover_text("Force re-render") + .clicked() + { + self.update_preview(); + } + if ui.button("Export WAV").on_hover_text("Ctrl+E").clicked() { + self.export_dialog.save_file(); + } + }); + } + + fn show_sequencer(&mut self, ui: &mut egui::Ui) { + ui.heading("Sequencer (Song)"); + + ui.horizontal(|ui| { + ui.label("BPM:"); + ui.add(egui::Slider::new(&mut self.song.bpm, 60.0..=240.0)); + ui.separator(); + ui.label("Rows/beat:"); + ui.add(egui::Slider::new(&mut self.song.rows_per_beat, 1..=16)); + }); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label(format!( + "Tracks: {} · Patterns: {} · Duration: {:.1}s", + self.song.tracks.len(), + self.song.patterns.len(), + self.song.duration() + )); + }); + + ui.separator(); + + // Action buttons + ui.horizontal(|ui| { + if ui.button("Format JSON").clicked() { + self.song_json = serde_json::to_string_pretty(&self.song).unwrap_or_default(); + self.song_json_error = None; + } + if ui.button("Validate").clicked() { + match serde_json::from_str::(&self.song_json) { + Ok(_) => { + self.song_json_error = None; + self.set_status("JSON valid".to_string()); + } + Err(e) => { + self.song_json_error = Some(format!("{}", e)); + } + } + } + if ui.button("New Song").clicked() { + self.song = default_song(); + self.song_json = serde_json::to_string_pretty(&self.song).unwrap_or_default(); + self.song_json_error = None; + } + if ui.button("Apply JSON").clicked() { + match serde_json::from_str::(&self.song_json) { + Ok(song) => { + self.song = song; + self.song_json_error = None; + self.set_status("Song updated".to_string()); + } + Err(e) => { + self.song_json_error = Some(format!("{}", e)); + } + } + } + if ui.button("▶ Play Song").clicked() { + let samples = render_song(&self.song); + self.start_playback(samples, self.song.sample_rate); + } + if ui.button("Export Song WAV").clicked() { + let samples = render_song(&self.song); + let path = std::path::Path::new("song_export.wav"); + match write_wav(path, &samples, self.song.sample_rate) { + Ok(()) => self.set_status(format!("Exported to {}", path.display())), + Err(e) => self.set_status(format!("Export error: {}", e)), + } + } + }); + + // Show error + if let Some(err) = &self.song_json_error { + ui.colored_label( + egui::Color32::from_rgb(255, 100, 100), + format!("Error: {}", err), + ); + } + + ui.separator(); + + // JSON editor + egui::ScrollArea::vertical() + .id_source("song_json") + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.song_json) + .code_editor() + .desired_width(f32::INFINITY) + .desired_rows(20), + ); + }); + } + + // ── Playback ────────────────────────────────────────── + + fn play_current(&mut self) { + let samples = render_spec(&self.spec); + self.preview_samples = downsample_for_display(&samples); + self.start_playback(samples, self.spec.sample_rate); + } + + fn start_playback(&mut self, samples: Vec, sample_rate: u32) { + // Stop any previous playback first (prevents Drop from blocking) + self.stop_playback(); + match play(&samples, sample_rate) { + Ok(handle) => { + self.playing = Some(Arc::new(Mutex::new(handle))); + self.set_status(format!("Playing '{}'...", self.spec.name)); + } + Err(e) => { + self.set_status(format!("Playback error: {}", e)); + } + } + } + + fn stop_playback(&mut self) { + if let Some(h) = self.playing.take() { + if let Ok(mut guard) = h.lock() { + guard.stop(); + } + } + } + + // ── Preview ─────────────────────────────────────────── + + fn mark_dirty(&mut self) { + self.preview_dirty = true; + } + + fn update_preview(&mut self) { + let samples = render_spec(&self.spec); + self.preview_samples = downsample_for_display(&samples); + } + + // ── File operations ─────────────────────────────────── + + fn new_sound(&mut self) { + self.undo.push(&self.spec); + self.spec = SoundSpec { + name: "new_sound".to_string(), + duration: 0.2, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: soundgen_core::FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + self.mark_dirty(); + self.set_status("New sound".to_string()); + } + + // ── Undo/Redo ───────────────────────────────────────── + + fn do_undo(&mut self) { + if let Some(prev) = self.undo.undo(&self.spec) { + self.spec = prev; + self.mark_dirty(); + self.set_status("Undo".to_string()); + } + } + + fn do_redo(&mut self) { + if let Some(next) = self.undo.redo(&self.spec) { + self.spec = next; + self.mark_dirty(); + self.set_status("Redo".to_string()); + } + } + + // ── Channels ────────────────────────────────────────── + + fn add_channel(&mut self, ch_type: ChannelType) { + self.undo.push(&self.spec); + match ch_type { + ChannelType::Pulse => { + self.spec.channels.push(ChannelSpec::Pulse { + duty: 50, + frequency: soundgen_core::FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }); + } + ChannelType::Triangle => { + self.spec.channels.push(ChannelSpec::Triangle { + frequency: soundgen_core::FrequencyAutomation::fixed(220.0), + envelope: None, + filter: None, + volume: 0.4, + pan: 0.0, + }); + } + ChannelType::Noise => { + self.spec.channels.push(ChannelSpec::Noise { + mode: "white".to_string(), + frequency: 8000.0, + envelope: None, + filter: None, + volume: 0.3, + pan: 0.0, + }); + } + } + self.mark_dirty(); + } + + // ── Status ──────────────────────────────────────────── + + fn set_status(&mut self, msg: String) { + self.status = msg; + self.status_time = Some(Instant::now()); + } +} + +// ── Helpers ────────────────────────────────────────────── + +fn set_channel_freq(mut ch: ChannelSpec, freq: f32) -> ChannelSpec { + match &mut ch { + ChannelSpec::Pulse { frequency, .. } => { + frequency.start = freq; + frequency.end = freq; + } + ChannelSpec::Triangle { frequency, .. } => { + frequency.start = freq; + frequency.end = freq; + } + ChannelSpec::Noise { .. } => {} + } + ch +} + +fn set_channel_vol(mut ch: ChannelSpec, vol: f32) -> ChannelSpec { + match &mut ch { + ChannelSpec::Pulse { volume, .. } => *volume = vol, + ChannelSpec::Triangle { volume, .. } => *volume = vol, + ChannelSpec::Noise { volume, .. } => *volume = vol, + } + ch +} + +fn downsample_for_display(samples: &[f32]) -> Vec { + // Interleaved stereo → mono + let mono: Vec = samples.iter().step_by(2).cloned().collect(); + let target_len = 1500usize; + if mono.len() > target_len { + let step = mono.len() / target_len; + mono.iter() + .step_by(step) + .take(target_len) + .cloned() + .collect() + } else { + mono + } +} diff --git a/crates/soundgen-gui/src/channel_panel.rs b/crates/soundgen-gui/src/channel_panel.rs new file mode 100644 index 0000000..93ea5a3 --- /dev/null +++ b/crates/soundgen-gui/src/channel_panel.rs @@ -0,0 +1,480 @@ +//! Channel panel — controls for duty cycle, frequency, envelope, filter, volume, pan. +//! +//! Features: +//! - Collapsible channels with color-coded headers +//! - Compact ADSR with mini visual shape +//! - Filter controls (type, cutoff, Q, sweep) +//! - Move up/down, duplicate, delete buttons + +use egui::{Color32, Ui}; +use soundgen_core::{FrequencyAutomation, SweepCurve}; +use soundgen_fmt::{ChannelSpec, CutoffAutomation, EnvelopeSpec, FilterKind, FilterSpec}; + +/// Channel type color for headers. +pub fn channel_type_color(channel: &ChannelSpec) -> Color32 { + match channel { + ChannelSpec::Pulse { .. } => Color32::from_rgb(255, 200, 80), // amber + ChannelSpec::Triangle { .. } => Color32::from_rgb(100, 255, 150), // green + ChannelSpec::Noise { .. } => Color32::from_rgb(180, 180, 190), // gray + } +} + +/// Channel type name. +pub fn channel_type_name(channel: &ChannelSpec) -> &'static str { + match channel { + ChannelSpec::Pulse { .. } => "Pulse", + ChannelSpec::Triangle { .. } => "Triangle", + ChannelSpec::Noise { .. } => "Noise", + } +} + +/// Draw controls for a channel spec inside a collapsing header. +/// Returns true if the spec was modified. +pub fn channel_panel( + ui: &mut Ui, + channel: &mut ChannelSpec, + index: usize, + total: usize, + id_source: &str, +) -> ChannelEdit { + let mut edit = ChannelEdit::default(); + let color = channel_type_color(channel); + let name = channel_type_name(channel); + + // Header with color bar + buttons + ui.horizontal(|ui| { + // Color indicator + let (bar_rect, _) = + ui.allocate_exact_size(egui::Vec2::new(4.0, 20.0), egui::Sense::hover()); + ui.painter().rect_filled(bar_rect, 2.0, color); + + // Collapsing header + let header_text = format!("Ch {} · {}", index + 1, name); + let id = format!("{}_{}", id_source, index); + egui::CollapsingHeader::new(header_text) + .id_source(id) + .default_open(true) + .show(ui, |ui| { + edit.changed = channel_controls(ui, channel, index); + }); + + // Move/dup/delete buttons + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("×").on_hover_text("Delete channel").clicked() { + edit.deleted = true; + } + if ui.button("⧉").on_hover_text("Duplicate channel").clicked() { + edit.duplicated = true; + } + ui.add_enabled_ui(index > 0, |ui| { + if ui.button("▲").on_hover_text("Move up").clicked() { + edit.moved_up = true; + } + }); + ui.add_enabled_ui(index < total - 1, |ui| { + if ui.button("▼").on_hover_text("Move down").clicked() { + edit.moved_down = true; + } + }); + }); + }); + + edit +} + +/// Actions from channel editing. +#[derive(Default)] +pub struct ChannelEdit { + pub changed: bool, + pub deleted: bool, + pub duplicated: bool, + pub moved_up: bool, + pub moved_down: bool, +} + +fn channel_controls(ui: &mut Ui, channel: &mut ChannelSpec, _index: usize) -> bool { + let mut changed = false; + + match channel { + ChannelSpec::Pulse { + duty, + frequency, + envelope, + filter, + volume, + pan, + } => { + // Duty cycle dropdown + ui.horizontal(|ui| { + ui.label("Duty:"); + let mut duty_val = *duty; + let resp = egui::ComboBox::from_id_source("duty") + .selected_text(format!("{}%", duty_val)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut duty_val, 12, "12.5%"); + ui.selectable_value(&mut duty_val, 25, "25%"); + ui.selectable_value(&mut duty_val, 50, "50%"); + ui.selectable_value(&mut duty_val, 75, "75%"); + }); + if resp.response.changed() { + *duty = duty_val; + changed = true; + } + }); + + changed |= freq_controls(ui, frequency); + changed |= envelope_controls(ui, envelope); + changed |= filter_controls(ui, filter); + changed |= vol_pan_controls(ui, volume, pan); + } + ChannelSpec::Triangle { + frequency, + envelope, + filter, + volume, + pan, + } => { + changed |= freq_controls(ui, frequency); + changed |= envelope_controls(ui, envelope); + changed |= filter_controls(ui, filter); + changed |= vol_pan_controls(ui, volume, pan); + } + ChannelSpec::Noise { + mode, + frequency, + envelope, + filter, + volume, + pan, + } => { + ui.horizontal(|ui| { + ui.label("Mode:"); + let mut is_white = *mode == "white"; + if ui.radio_value(&mut is_white, true, "White").changed() { + *mode = "white".to_string(); + changed = true; + } + if ui.radio_value(&mut is_white, false, "Periodic").changed() { + *mode = "periodic".to_string(); + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label("Clock:"); + if ui + .add( + egui::Slider::new(frequency, 100.0..=20000.0) + .logarithmic(true) + .suffix(" Hz"), + ) + .changed() + { + changed = true; + } + }); + changed |= envelope_controls(ui, envelope); + changed |= filter_controls(ui, filter); + changed |= vol_pan_controls(ui, volume, pan); + } + } + + changed +} + +fn freq_controls(ui: &mut Ui, freq: &mut FrequencyAutomation) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Freq:"); + if ui + .add( + egui::Slider::new(&mut freq.start, 20.0..=8000.0) + .logarithmic(true) + .suffix(" Hz") + .text("start"), + ) + .on_hover_text("Starting frequency") + .changed() + { + changed = true; + } + if ui + .add( + egui::Slider::new(&mut freq.end, 20.0..=8000.0) + .logarithmic(true) + .suffix(" Hz") + .text("end"), + ) + .on_hover_text("Ending frequency (sweep target)") + .changed() + { + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label("Curve:"); + if ui + .radio_value(&mut freq.curve, SweepCurve::Linear, "Linear") + .changed() + { + changed = true; + } + if ui + .radio_value(&mut freq.curve, SweepCurve::Exponential, "Exponential") + .changed() + { + changed = true; + } + }); + changed +} + +fn envelope_controls(ui: &mut Ui, envelope: &mut Option) -> bool { + let mut changed = false; + + if envelope.is_none() { + *envelope = Some(EnvelopeSpec::default()); + } + + let env = envelope.as_mut().unwrap(); + + // ADSR visual + sliders in compact layout + ui.horizontal(|ui| { + // Mini ADSR visual + crate::waveform::adsr_visual(ui, env.attack, env.decay, env.sustain, env.release, 1.0); + + // Compact sliders in a grid + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("A:"); + if ui + .add( + egui::Slider::new(&mut env.attack, 0.0..=2.0) + .suffix("s") + .clamp_to_range(true) + .fixed_decimals(3), + ) + .on_hover_text("Attack time (seconds)") + .changed() + { + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label("D:"); + if ui + .add( + egui::Slider::new(&mut env.decay, 0.0..=2.0) + .suffix("s") + .clamp_to_range(true) + .fixed_decimals(3), + ) + .on_hover_text("Decay time (seconds)") + .changed() + { + changed = true; + } + }); + }); + }); + + ui.horizontal(|ui| { + ui.label("S:"); + if ui + .add( + egui::Slider::new(&mut env.sustain, 0.0..=1.0) + .clamp_to_range(true) + .fixed_decimals(2), + ) + .on_hover_text("Sustain level (0-1)") + .changed() + { + changed = true; + } + ui.label("R:"); + if ui + .add( + egui::Slider::new(&mut env.release, 0.0..=2.0) + .suffix("s") + .clamp_to_range(true) + .fixed_decimals(3), + ) + .on_hover_text("Release time (seconds)") + .changed() + { + changed = true; + } + }); + + changed +} + +fn filter_controls(ui: &mut Ui, filter: &mut Option) -> bool { + let mut changed = false; + + let has_filter = filter.is_some(); + + ui.horizontal(|ui| { + let mut enable = has_filter; + if ui + .checkbox(&mut enable, "Filter") + .on_hover_text("Enable biquad filter") + .changed() + { + if enable && filter.is_none() { + *filter = Some(FilterSpec { + kind: FilterKind::Lowpass, + cutoff: 2000.0, + cutoff_sweep: None, + q: 0.707, + }); + changed = true; + } else if !enable && filter.is_some() { + *filter = None; + changed = true; + } + } + }); + + if let Some(filt) = filter { + ui.horizontal(|ui| { + ui.label("Type:"); + let mut kind = filt.kind; + let resp = egui::ComboBox::from_id_source("filter_type") + .selected_text(match kind { + FilterKind::Lowpass => "Lowpass", + FilterKind::Highpass => "Highpass", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut kind, FilterKind::Lowpass, "Lowpass"); + ui.selectable_value(&mut kind, FilterKind::Highpass, "Highpass"); + }); + if resp.response.changed() { + filt.kind = kind; + changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Cutoff:"); + if ui + .add( + egui::Slider::new(&mut filt.cutoff, 20.0..=20000.0) + .logarithmic(true) + .suffix(" Hz"), + ) + .on_hover_text("Filter cutoff frequency") + .changed() + { + changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Q:"); + if ui + .add( + egui::Slider::new(&mut filt.q, 0.1..=10.0) + .fixed_decimals(2) + .text("resonance"), + ) + .on_hover_text("Filter resonance (higher = sharper)") + .changed() + { + changed = true; + } + }); + + // Cutoff sweep + ui.horizontal(|ui| { + let mut has_sweep = filt.cutoff_sweep.is_some(); + if ui + .checkbox(&mut has_sweep, "Cutoff sweep") + .on_hover_text("Automate cutoff over time") + .changed() + { + if has_sweep { + filt.cutoff_sweep = Some(CutoffAutomation { + start: filt.cutoff, + end: filt.cutoff * 0.1, + curve: SweepCurve::Exponential, + }); + changed = true; + } else { + filt.cutoff_sweep = None; + changed = true; + } + } + }); + + if let Some(sweep) = &mut filt.cutoff_sweep { + ui.horizontal(|ui| { + ui.label(" Start:"); + if ui + .add( + egui::Slider::new(&mut sweep.start, 20.0..=20000.0) + .logarithmic(true) + .suffix(" Hz"), + ) + .changed() + { + changed = true; + } + ui.label("End:"); + if ui + .add( + egui::Slider::new(&mut sweep.end, 20.0..=20000.0) + .logarithmic(true) + .suffix(" Hz"), + ) + .changed() + { + changed = true; + } + }); + ui.horizontal(|ui| { + ui.label(" Curve:"); + if ui + .radio_value(&mut sweep.curve, SweepCurve::Linear, "Linear") + .changed() + { + changed = true; + } + if ui + .radio_value(&mut sweep.curve, SweepCurve::Exponential, "Exponential") + .changed() + { + changed = true; + } + }); + } + } + + changed +} + +fn vol_pan_controls(ui: &mut Ui, volume: &mut f32, pan: &mut f32) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label("Vol:"); + if ui + .add(egui::Slider::new(volume, 0.0..=1.0).fixed_decimals(2)) + .on_hover_text("Channel volume (0-1)") + .changed() + { + changed = true; + } + ui.label("Pan:"); + if ui + .add( + egui::Slider::new(pan, -1.0..=1.0) + .fixed_decimals(2) + .text("L/R"), + ) + .on_hover_text("Pan (-1=left, 0=center, 1=right)") + .changed() + { + changed = true; + } + }); + changed +} diff --git a/crates/soundgen-gui/src/keyboard.rs b/crates/soundgen-gui/src/keyboard.rs new file mode 100644 index 0000000..f5386c8 --- /dev/null +++ b/crates/soundgen-gui/src/keyboard.rs @@ -0,0 +1,328 @@ +//! Virtual piano keyboard widget for egui. +//! +//! Features: +//! - Responsive key sizing (fits available width) +//! - Octave shift (Z/X keys or ◀ ▶ buttons) +//! - QWERTY letter labels on keys +//! - Velocity slider +//! - Mouse + keyboard input + +use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2}; + +/// Convert MIDI note number to frequency. +pub fn midi_to_freq(midi: u8) -> f32 { + 440.0 * 2.0f32.powf((midi as f32 - 69.0) / 12.0) +} + +/// Is this MIDI note a black key? +fn is_black_key(midi: u8) -> bool { + let n = midi % 12; + n == 1 || n == 3 || n == 6 || n == 8 || n == 10 +} + +/// QWERTY-to-semitone mapping (relative to start_note). +const QWERTY_MAP: &[(egui::Key, &str, u8)] = &[ + (egui::Key::A, "A", 0), + (egui::Key::W, "W", 1), + (egui::Key::S, "S", 2), + (egui::Key::E, "E", 3), + (egui::Key::D, "D", 4), + (egui::Key::F, "F", 5), + (egui::Key::T, "T", 6), + (egui::Key::G, "G", 7), + (egui::Key::Y, "Y", 8), + (egui::Key::H, "H", 9), + (egui::Key::U, "U", 10), + (egui::Key::J, "J", 11), + (egui::Key::K, "K", 12), + (egui::Key::O, "O", 13), + (egui::Key::L, "L", 14), +]; + +/// Piano keyboard widget. +pub struct Keyboard { + /// Lowest MIDI note (changes with octave shift) + pub start_note: u8, + /// Number of octaves to display + pub octaves: usize, + /// Currently pressed notes (MIDI numbers) + pub pressed: std::collections::HashSet, + /// Velocity (0-127) + pub velocity: u8, + /// Responsive key width (computed each frame) + key_w: f32, + /// White key height + key_h: f32, + /// Black key width + black_key_w: f32, + /// Black key height + black_key_h: f32, +} + +impl Keyboard { + pub fn new(start_note: u8, octaves: usize) -> Self { + Self { + start_note, + octaves, + pressed: std::collections::HashSet::new(), + velocity: 100, + key_w: 28.0, + key_h: 90.0, + black_key_w: 18.0, + black_key_h: 56.0, + } + } + + /// Shift octave down. + pub fn octave_down(&mut self) { + if self.start_note >= 12 { + self.start_note -= 12; + } + } + + /// Shift octave up. + pub fn octave_up(&mut self) { + if self.start_note + 12 * self.octaves as u8 <= 120 { + self.start_note += 12; + } + } + + /// Get the current octave range label (e.g., "C4–C6"). + pub fn range_label(&self) -> String { + let bottom = self.start_note; + let top = self.start_note + 12 * self.octaves as u8; + format!("{}–{}", note_name(bottom), note_name(top)) + } + + /// Draw the keyboard and handle input. + /// Returns (newly_pressed, newly_released, velocity). + pub fn show(&mut self, ui: &mut Ui) -> (Vec, Vec, u8) { + let mut new_presses = Vec::new(); + let mut new_releases = Vec::new(); + + // Octave shift controls + velocity + ui.horizontal(|ui| { + if ui.button("◀").on_hover_text("Octave down (Z)").clicked() { + self.octave_down(); + } + ui.label( + egui::RichText::new(self.range_label()) + .strong() + .color(Color32::from_rgb(140, 180, 255)), + ); + if ui.button("▶").on_hover_text("Octave up (X)").clicked() { + self.octave_up(); + } + ui.separator(); + ui.label("Vel:"); + ui.add( + egui::Slider::new(&mut self.velocity, 1..=127) + .clamp_to_range(true) + .text(""), + ) + .on_hover_text("Velocity (loudness)"); + }); + + // Compute responsive key width + let num_white_keys = self.octaves * 7 + 1; + let avail_w = ui.available_width().min(num_white_keys as f32 * 40.0); + self.key_w = (avail_w / num_white_keys as f32).clamp(16.0, 40.0); + self.black_key_w = self.key_w * 0.6; + self.key_h = 90.0; + self.black_key_h = 56.0; + + let total_w = num_white_keys as f32 * self.key_w; + let (rect, _) = + ui.allocate_exact_size(Vec2::new(total_w, self.key_h), Sense::click_and_drag()); + + let painter = ui.painter_at(rect); + + // Collect all notes to draw + let mut notes: Vec = Vec::new(); + let mut white_count = 0u32; + let mut midi = self.start_note; + for _ in 0..(num_white_keys * 2) { + if midi > 127 { + break; + } + notes.push(midi); + if !is_black_key(midi) { + white_count += 1; + } + if white_count >= num_white_keys as u32 { + break; + } + midi += 1; + } + + // Draw white keys + let mut white_x = rect.left(); + let mut white_key_positions: Vec<(u8, Rect, &str)> = Vec::new(); + for ¬e in ¬es { + if is_black_key(note) { + continue; + } + let key_rect = Rect::from_min_size( + Pos2::new(white_x, rect.top()), + Vec2::new(self.key_w, self.key_h), + ); + let is_pressed = self.pressed.contains(¬e); + let color = if is_pressed { + Color32::from_rgb(100, 160, 255) + } else { + Color32::from_rgb(235, 235, 240) + }; + painter.rect_filled(key_rect, 3.0, color); + painter.rect_stroke(key_rect, 3.0, (1.0, Color32::from_rgb(60, 60, 70))); + + // C note labels + if note % 12 == 0 { + painter.text( + Pos2::new(white_x + self.key_w * 0.5, rect.bottom() - 14.0), + egui::Align2::CENTER_CENTER, + note_name(note), + egui::FontId::proportional(11.0), + Color32::from_rgb(100, 100, 120), + ); + } + + // QWERTY label + if let Some(label) = qwerty_label_for_note(note, self.start_note) { + painter.text( + Pos2::new(white_x + self.key_w * 0.5, rect.bottom() - 28.0), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(9.0), + Color32::from_rgba_premultiplied(120, 120, 140, 100), + ); + } + + white_key_positions.push((note, key_rect, "")); + white_x += self.key_w; + } + + // Draw black keys (on top) + let mut white_x = rect.left(); + let mut black_key_positions: Vec<(u8, Rect)> = Vec::new(); + for ¬e in ¬es { + if is_black_key(note) { + let black_x = white_x - self.black_key_w * 0.5; + let key_rect = Rect::from_min_size( + Pos2::new(black_x, rect.top()), + Vec2::new(self.black_key_w, self.black_key_h), + ); + let is_pressed = self.pressed.contains(¬e); + let color = if is_pressed { + Color32::from_rgb(70, 100, 200) + } else { + Color32::from_rgb(35, 35, 42) + }; + painter.rect_filled(key_rect, 2.0, color); + painter.rect_stroke(key_rect, 2.0, (1.0, Color32::from_rgb(80, 80, 90))); + + // QWERTY label on black key + if let Some(label) = qwerty_label_for_note(note, self.start_note) { + painter.text( + Pos2::new( + black_x + self.black_key_w * 0.5, + rect.top() + self.black_key_h - 14.0, + ), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(8.0), + Color32::from_rgba_premultiplied(180, 180, 200, 120), + ); + } + + black_key_positions.push((note, key_rect)); + } else { + white_x += self.key_w; + } + } + + // Handle mouse input + let mouse_pos = ui.input(|i| i.pointer.hover_pos()); + let mouse_down = ui.input(|i| i.pointer.primary_down()); + let mouse_released = ui.input(|i| i.pointer.primary_released()); + + if let Some(pos) = mouse_pos { + if mouse_down || mouse_released { + // Check black keys first (on top) + let mut hit_note: Option = None; + for (note, kr) in &black_key_positions { + if kr.contains(pos) { + hit_note = Some(*note); + break; + } + } + if hit_note.is_none() { + for (note, kr, _) in &white_key_positions { + if kr.contains(pos) { + hit_note = Some(*note); + break; + } + } + } + + if let Some(note) = hit_note { + if mouse_down && !self.pressed.contains(¬e) { + self.pressed.insert(note); + new_presses.push(note); + } + } + + if mouse_released { + let released: Vec = self.pressed.iter().copied().collect(); + for n in released { + self.pressed.remove(&n); + new_releases.push(n); + } + } + } + } + + // Handle QWERTY keyboard input + for (key, _label, semitone) in QWERTY_MAP { + let midi = self.start_note + semitone; + let pressed_now = ui.input(|i| i.key_down(*key)); + let was_pressed = self.pressed.contains(&midi); + + if pressed_now && !was_pressed { + self.pressed.insert(midi); + new_presses.push(midi); + } else if !pressed_now && was_pressed { + self.pressed.remove(&midi); + new_releases.push(midi); + } + } + + // Handle octave shift keys (Z / X) + if ui.input(|i| i.key_pressed(egui::Key::Z)) { + self.octave_down(); + } + if ui.input(|i| i.key_pressed(egui::Key::X)) { + self.octave_up(); + } + + (new_presses, new_releases, self.velocity) + } +} + +/// Get the QWERTY letter label for a given MIDI note (if mapped). +fn qwerty_label_for_note(note: u8, start_note: u8) -> Option<&'static str> { + let semitone = note - start_note; + QWERTY_MAP + .iter() + .find(|(_, _, s)| *s == semitone) + .map(|(_, label, _)| *label) +} + +/// Get note name (e.g., "C4", "A#3"). +fn note_name(midi: u8) -> String { + const NAMES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let octave = midi / 12 - 1; + let note = midi % 12; + format!("{}{}", NAMES[note as usize], octave) +} diff --git a/crates/soundgen-gui/src/main.rs b/crates/soundgen-gui/src/main.rs new file mode 100644 index 0000000..920761c --- /dev/null +++ b/crates/soundgen-gui/src/main.rs @@ -0,0 +1,42 @@ +//! Soundgen GUI — egui-based 8-bit sound editor. +//! +//! Features: +//! - Virtual piano keyboard (mouse + QWERTY, octave shift, velocity) +//! - SFX editor with collapsible channels, ADSR visual, filter controls +//! - Preset browser with click-to-play, color-coded categories +//! - Sequencer with JSON editor +//! - Undo/redo, hotkeys, file dialogs +//! - Save/load projects (JSON), WAV export + +mod app; +mod channel_panel; +mod keyboard; +mod preset_browser; +mod waveform; + +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1280.0, 820.0]) + .with_title("Soundgen — 8-bit Sound Synthesizer"), + ..Default::default() + }; + + eframe::run_native( + "Soundgen", + options, + Box::new(|cc| { + setup_custom_theme(&cc.egui_ctx); + Ok(Box::new(app::SoundgenApp::default())) + }), + ) +} + +fn setup_custom_theme(ctx: &egui::Context) { + let mut theme = egui::Visuals::dark(); + theme.panel_fill = egui::Color32::from_rgb(24, 24, 28); + theme.window_fill = egui::Color32::from_rgb(32, 32, 38); + theme.extreme_bg_color = egui::Color32::from_rgb(16, 16, 20); + theme.faint_bg_color = egui::Color32::from_rgb(40, 40, 48); + ctx.set_visuals(theme); +} diff --git a/crates/soundgen-gui/src/preset_browser.rs b/crates/soundgen-gui/src/preset_browser.rs new file mode 100644 index 0000000..b6555c7 --- /dev/null +++ b/crates/soundgen-gui/src/preset_browser.rs @@ -0,0 +1,136 @@ +//! Preset browser — list, filter, and play presets with color-coded categories. + +use egui::{Color32, RichText, Ui}; +use soundgen_fmt::{PresetCategory, PresetRegistry}; + +pub struct PresetBrowser { + pub selected: Option, + pub filter: String, +} + +impl PresetBrowser { + pub fn new() -> Self { + Self { + selected: None, + filter: String::new(), + } + } + + /// Draw the browser. Returns the name of a preset to play (double-click or play button). + pub fn show(&mut self, ui: &mut Ui, registry: &PresetRegistry) -> Option { + let mut to_play = None; + + ui.heading("Presets"); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.filter) + .on_hover_text("Filter by name..."); + if !self.filter.is_empty() { + if ui.button("✕").clicked() { + self.filter.clear(); + } + } + }); + + egui::ScrollArea::vertical().show(ui, |ui| { + let filter_lower = self.filter.to_lowercase(); + + for category in [ + PresetCategory::Sfx, + PresetCategory::Ui, + PresetCategory::Ambient, + ] { + let presets: Vec<_> = registry + .list(Some(category)) + .into_iter() + .filter(|p| p.name.to_lowercase().contains(&filter_lower)) + .collect(); + + if presets.is_empty() { + continue; + } + + let cat_color = category_color(category); + let header = format!("{} ({})", category.as_str().to_uppercase(), presets.len()); + + ui.collapsing(header, |ui| { + for entry in &presets { + let is_selected = self.selected.as_deref() == Some(entry.name.as_str()); + + let bg = if is_selected { + Color32::from_rgb(50, 70, 110) + } else { + Color32::from_rgb(35, 35, 42) + }; + + let frame = egui::Frame::group(ui.style()) + .fill(bg) + .stroke(egui::Stroke::new( + if is_selected { 2.0 } else { 0.5 }, + if is_selected { + cat_color + } else { + Color32::from_gray(60) + }, + )) + .inner_margin(egui::Margin::symmetric(6.0, 4.0)); + + let response = frame.show(ui, |ui| { + ui.horizontal(|ui| { + // Category color dot + let (dot_rect, _) = ui.allocate_exact_size( + egui::Vec2::new(8.0, 8.0), + egui::Sense::hover(), + ); + ui.painter() + .circle_filled(dot_rect.center(), 4.0, cat_color); + + // Name + ui.label(RichText::new(&entry.name).strong()); + + // Info + ui.label( + RichText::new(format!( + "{:.2}s · {}ch", + entry.spec.duration, + entry.spec.channels.len() + )) + .small() + .weak(), + ); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("▶").on_hover_text("Play").clicked() { + to_play = Some(entry.name.clone()); + self.selected = Some(entry.name.clone()); + } + }, + ); + }); + }); + + let resp = response.response.interact(egui::Sense::click()); + if resp.clicked() { + self.selected = Some(entry.name.clone()); + } + if resp.double_clicked() { + to_play = Some(entry.name.clone()); + self.selected = Some(entry.name.clone()); + } + } + }); + } + }); + + to_play + } +} + +fn category_color(cat: PresetCategory) -> Color32 { + match cat { + PresetCategory::Sfx => Color32::from_rgb(255, 120, 80), + PresetCategory::Ui => Color32::from_rgb(120, 200, 255), + PresetCategory::Ambient => Color32::from_rgb(150, 255, 150), + } +} diff --git a/crates/soundgen-gui/src/waveform.rs b/crates/soundgen-gui/src/waveform.rs new file mode 100644 index 0000000..17b7abc --- /dev/null +++ b/crates/soundgen-gui/src/waveform.rs @@ -0,0 +1,249 @@ +//! Waveform display widget with time grid, channel colors, and RMS meter. + +use egui::{Color32, FontId, Pos2, Rect, Ui, Vec2}; + +/// Channel type colors for waveform display. +#[allow(dead_code)] +pub fn channel_color(index: usize) -> Color32 { + match index % 6 { + 0 => Color32::from_rgb(100, 200, 255), + 1 => Color32::from_rgb(255, 200, 100), + 2 => Color32::from_rgb(150, 255, 150), + 3 => Color32::from_rgb(255, 150, 150), + 4 => Color32::from_rgb(200, 150, 255), + _ => Color32::from_rgb(255, 255, 150), + } +} + +/// Draw a waveform preview with time grid and labels. +/// +/// - `samples`: mono samples (downsampled for display) +/// - `duration`: total duration in seconds +/// - `height`: widget height in pixels +pub fn waveform_display(ui: &mut Ui, samples: &[f32], duration: f32, height: f32) { + let avail_w = ui.available_width(); + let size = Vec2::new(avail_w, height); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let painter = ui.painter_at(rect); + + // Inset: leave room for labels at bottom and RMS meter at right + let label_h = 14.0; + let meter_w = 8.0; + let pad = 4.0; + let wave_rect = Rect::from_min_max( + Pos2::new(rect.left() + pad, rect.top() + pad), + Pos2::new(rect.right() - meter_w - pad * 2.0, rect.bottom() - label_h), + ); + + // Background + painter.rect_filled(rect, 4.0, Color32::from_rgb(18, 18, 24)); + + if samples.len() < 2 { + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "No preview", + FontId::proportional(14.0), + Color32::from_rgb(80, 80, 100), + ); + return; + } + + // Time grid + let grid_interval = if duration < 0.5 { + 0.05 + } else if duration < 2.0 { + 0.1 + } else if duration < 10.0 { + 0.5 + } else { + 1.0 + }; + let n_lines = (duration / grid_interval) as usize; + for i in 0..=n_lines { + let t = i as f32 * grid_interval; + let x = wave_rect.left() + (t / duration) * wave_rect.width(); + let alpha = if i == 0 || i == n_lines { 50 } else { 25 }; + painter.line_segment( + [ + Pos2::new(x, wave_rect.top()), + Pos2::new(x, wave_rect.bottom()), + ], + (1.0, Color32::from_rgba_premultiplied(80, 80, 100, alpha)), + ); + if i > 0 && i < n_lines { + painter.text( + Pos2::new(x, wave_rect.bottom() + 2.0), + egui::Align2::CENTER_TOP, + if grid_interval < 1.0 { + format!("{:.0}ms", t * 1000.0) + } else { + format!("{:.1}s", t) + }, + FontId::proportional(8.0), + Color32::from_rgb(70, 70, 90), + ); + } + } + + // Center line + let mid_y = wave_rect.center().y; + painter.line_segment( + [ + Pos2::new(wave_rect.left(), mid_y), + Pos2::new(wave_rect.right(), mid_y), + ], + (1.0, Color32::from_rgba_premultiplied(60, 60, 80, 60)), + ); + + // Waveform line + let amp = wave_rect.height() * 0.45; + let color = Color32::from_rgb(100, 200, 255); + let fill_color = Color32::from_rgba_premultiplied(100, 200, 255, 40); + + let points: Vec = samples + .iter() + .enumerate() + .map(|(i, &s)| { + let x = wave_rect.left() + (i as f32 / samples.len() as f32) * wave_rect.width(); + let y = mid_y - s.clamp(-1.0, 1.0) * amp; + Pos2::new(x, y) + }) + .collect(); + + // Fill under curve: draw vertical lines from each point to mid_y + for p in &points { + painter.line_segment([Pos2::new(p.x, mid_y), *p], (1.0, fill_color)); + } + + // Waveform line on top + painter.add(egui::Shape::line(points, (1.5, color))); + + // RMS meter (right side, separate from waveform area) + let rms = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); + let rms_db = 20.0 * rms.max(1e-6).log10(); + + let meter_rect = Rect::from_min_size( + Pos2::new(rect.right() - meter_w - pad, wave_rect.top()), + Vec2::new(meter_w, wave_rect.height()), + ); + painter.rect_filled(meter_rect, 2.0, Color32::from_rgb(30, 30, 40)); + + let level = ((rms_db + 60.0) / 60.0).clamp(0.0, 1.0); + let level_h = level * meter_rect.height(); + let level_color = if level > 0.85 { + Color32::from_rgb(255, 80, 80) + } else if level > 0.6 { + Color32::from_rgb(255, 200, 80) + } else { + Color32::from_rgb(80, 200, 120) + }; + painter.rect_filled( + Rect::from_min_size( + Pos2::new(meter_rect.left(), meter_rect.bottom() - level_h), + Vec2::new(meter_w, level_h), + ), + 2.0, + level_color, + ); + + // Info text (top-left, inside wave rect) + painter.text( + Pos2::new(wave_rect.left() + 4.0, wave_rect.top() + 2.0), + egui::Align2::LEFT_TOP, + format!("{:.2}s · RMS {:.1} dB", duration, rms_db), + FontId::proportional(10.0), + Color32::from_rgb(120, 140, 160), + ); +} + +/// Draw a mini ADSR envelope shape. +pub fn adsr_visual( + ui: &mut Ui, + attack: f32, + decay: f32, + sustain: f32, + release: f32, + _total_dur: f32, +) { + let width = 120.0; + let height = 50.0; + let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover()); + let painter = ui.painter_at(rect); + + // Background + painter.rect_filled(rect, 3.0, Color32::from_rgb(20, 20, 28)); + + let pad = 3.0; + let inner = Rect::from_min_max( + Pos2::new(rect.left() + pad, rect.top() + pad), + Pos2::new(rect.right() - pad, rect.bottom() - pad), + ); + + // Calculate segment boundaries + let total = attack + decay + 0.3 + release; + let total = total.max(0.01); + let atk_x = inner.left() + (attack / total) * inner.width(); + let dec_x = atk_x + (decay / total) * inner.width(); + let sus_end = inner.right() - (release / total) * inner.width(); + + let top = inner.top(); + let bottom = inner.bottom(); + let sustain_y = bottom - sustain * (bottom - top); + + // Envelope points + let points = vec![ + Pos2::new(inner.left(), bottom), + Pos2::new(atk_x, top), + Pos2::new(dec_x, sustain_y), + Pos2::new(sus_end, sustain_y), + Pos2::new(inner.right(), bottom), + ]; + + // Fill: draw as a series of triangles fan from bottom-left + let fill_color = Color32::from_rgba_premultiplied(120, 180, 255, 30); + for w in points.windows(2) { + let tri = vec![Pos2::new(inner.left(), bottom), w[0], w[1]]; + painter.add(egui::Shape::convex_polygon( + tri, + fill_color, + (0.0, Color32::TRANSPARENT), + )); + } + + // Envelope line + let color = Color32::from_rgb(120, 180, 255); + painter.add(egui::Shape::line(points, (2.0, color))); + + // Segment labels + let label_color = Color32::from_rgb(100, 120, 140); + let font = FontId::proportional(8.0); + painter.text( + Pos2::new((inner.left() + atk_x) * 0.5, bottom + 1.0), + egui::Align2::CENTER_TOP, + "A", + font.clone(), + label_color, + ); + painter.text( + Pos2::new((atk_x + dec_x) * 0.5, bottom + 1.0), + egui::Align2::CENTER_TOP, + "D", + font.clone(), + label_color, + ); + painter.text( + Pos2::new((dec_x + sus_end) * 0.5, bottom + 1.0), + egui::Align2::CENTER_TOP, + "S", + font.clone(), + label_color, + ); + painter.text( + Pos2::new((sus_end + inner.right()) * 0.5, bottom + 1.0), + egui::Align2::CENTER_TOP, + "R", + font, + label_color, + ); +} diff --git a/crates/soundgen-io/Cargo.toml b/crates/soundgen-io/Cargo.toml new file mode 100644 index 0000000..7aefd83 --- /dev/null +++ b/crates/soundgen-io/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "soundgen-io" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +realtime = ["dep:cpal"] + +[dependencies] +soundgen-core.workspace = true +soundgen-fmt.workspace = true +hound.workspace = true +cpal = { workspace = true, optional = true } \ No newline at end of file diff --git a/crates/soundgen-io/src/lib.rs b/crates/soundgen-io/src/lib.rs new file mode 100644 index 0000000..d427d8a --- /dev/null +++ b/crates/soundgen-io/src/lib.rs @@ -0,0 +1,14 @@ +//! WAV I/O using `hound`. +//! Audio playback via subprocess (paplay/aplay) or cpal (realtime feature). + +pub mod player; +pub mod wav; + +pub use player::{play, play_spec, PlaybackHandle}; +pub use wav::{write_wav, write_wav_bits, write_wav_mono, write_wav_mono_bits}; + +#[cfg(feature = "realtime")] +pub mod realtime; + +#[cfg(feature = "realtime")] +pub use realtime::AudioPlayer; diff --git a/crates/soundgen-io/src/player.rs b/crates/soundgen-io/src/player.rs new file mode 100644 index 0000000..fb4efd2 --- /dev/null +++ b/crates/soundgen-io/src/player.rs @@ -0,0 +1,104 @@ +//! Audio playback — uses cpal if realtime feature is enabled, +//! otherwise falls back to subprocess (paplay/aplay). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +static FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Play interleaved stereo samples. Returns a handle that can be used to stop. +pub fn play(samples: &[f32], sample_rate: u32) -> Result { + // Write to a unique temp WAV (unique per call, not per process) + let id = FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let tmp = std::env::temp_dir().join(format!( + "soundgen_preview_{}_{}.wav", + std::process::id(), + id + )); + crate::write_wav(&tmp, samples, sample_rate)?; + + // Try paplay, then aplay, then pw-play + let child = Command::new("paplay") + .arg(&tmp) + .spawn() + .or_else(|_| Command::new("aplay").arg("-q").arg(&tmp).spawn()) + .or_else(|_| Command::new("pw-play").arg(&tmp).spawn()) + .map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!( + "no audio player available (tried paplay, aplay, pw-play): {}", + e + ) + })?; + + Ok(PlaybackHandle { + child: Some(child), + tmp_file: Some(tmp), + stopped: Arc::new(AtomicBool::new(false)), + }) +} + +/// Handle to a playing sound. Drop to let it finish naturally. +pub struct PlaybackHandle { + child: Option, + tmp_file: Option, + stopped: Arc, +} + +impl PlaybackHandle { + /// Check if playback is still running. + pub fn is_playing(&mut self) -> bool { + if self.stopped.load(Ordering::Relaxed) { + return false; + } + match &mut self.child { + Some(child) => match child.try_wait() { + Ok(Some(_)) => false, + Ok(None) => true, + Err(_) => false, + }, + None => false, + } + } + + /// Stop playback — kills the subprocess and cleans up. + pub fn stop(&mut self) { + if self.stopped.swap(true, Ordering::Relaxed) { + return; // already stopped + } + if let Some(child) = &mut self.child { + let _ = child.kill(); + } + // Clean up temp file immediately on explicit stop + if let Some(path) = &self.tmp_file { + let _ = std::fs::remove_file(path); + } + self.tmp_file = None; + } +} + +impl Drop for PlaybackHandle { + fn drop(&mut self) { + // On drop: if not explicitly stopped, let the subprocess finish + // naturally and clean up the temp file after. + if !self.stopped.load(Ordering::Relaxed) { + // Wait briefly for the child to finish, then clean up + if let Some(child) = &mut self.child { + // Give it up to 5 seconds to finish + let _ = child.wait(); + } + if let Some(path) = &self.tmp_file { + let _ = std::fs::remove_file(path); + } + } + // If explicitly stopped, cleanup already happened in stop() + } +} + +/// Play a SoundSpec by rendering it and playing the result. +pub fn play_spec(spec: &soundgen_fmt::SoundSpec) -> Result { + let samples = soundgen_fmt::render_spec(spec); + play(&samples, spec.sample_rate) +} diff --git a/crates/soundgen-io/src/realtime.rs b/crates/soundgen-io/src/realtime.rs new file mode 100644 index 0000000..7553b77 --- /dev/null +++ b/crates/soundgen-io/src/realtime.rs @@ -0,0 +1,206 @@ +//! Realtime audio playback via cpal. +//! +//! Pre-renders sound to a buffer and plays it through the default audio device. +//! Suitable for SFX preview and short clips. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +/// A shared playback buffer with atomic read position. +/// The audio callback reads from this; the main thread fills it. +struct PlaybackState { + buffer: Vec, + position: AtomicUsize, + channels: u16, +} + +/// Realtime audio player. Drop to stop playback. +pub struct AudioPlayer { + _stream: cpal::Stream, + state: Arc, +} + +impl AudioPlayer { + /// Play interleaved stereo samples through the default audio device. + pub fn play(samples: &[f32], sample_rate: u32) -> Result { + Self::play_with_channels(samples, sample_rate, 2) + } + + /// Play interleaved samples with the given channel count. + pub fn play_with_channels( + samples: &[f32], + sample_rate: u32, + channels: u16, + ) -> Result { + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or("no audio output device available")?; + + let supported_config = device + .supported_output_configs() + .map_err(|e| format!("enumerate configs: {}", e))? + .find(|c| c.channels() == channels) + .or_else(|| { + device + .supported_output_configs() + .ok() + .and_then(|mut c| c.next()) + }) + .ok_or("no supported output config")?; + + let config = supported_config + .with_sample_rate(cpal::SampleRate(sample_rate)) + .config(); + + let state = Arc::new(PlaybackState { + buffer: samples.to_vec(), + position: AtomicUsize::new(0), + channels: config.channels, + }); + + let state_clone = Arc::clone(&state); + let data_type = config.sample_format; + + let stream = match data_type { + cpal::SampleFormat::F32 => { + let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + read_samples(&state_clone, data); + }; + device + .build_output_stream( + &config, + data_fn, + |err| eprintln!("audio error: {}", err), + None, + ) + .map_err(|e| format!("build stream: {}", e))? + } + cpal::SampleFormat::I16 => { + let data_fn = move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + read_samples_i16(&state_clone, data); + }; + device + .build_output_stream( + &config, + data_fn, + |err| eprintln!("audio error: {}", err), + None, + ) + .map_err(|e| format!("build stream: {}", e))? + } + cpal::SampleFormat::U16 => { + let data_fn = move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + read_samples_u16(&state_clone, data); + }; + device + .build_output_stream( + &config, + data_fn, + |err| eprintln!("audio error: {}", err), + None, + ) + .map_err(|e| format!("build stream: {}", e))? + } + _ => return Err("unsupported sample format".to_string()), + }; + + stream.play().map_err(|e| format!("start stream: {}", e))?; + + Ok(Self { + _stream: stream, + state, + }) + } + + /// Check if playback has finished. + pub fn is_finished(&self) -> bool { + self.state.position.load(Ordering::Relaxed) >= self.state.buffer.len() + } + + /// Stop playback. + pub fn stop(&self) { + self.state + .position + .store(self.state.buffer.len(), Ordering::Relaxed); + } +} + +fn read_samples(state: &PlaybackState, output: &mut [f32]) { + let mut pos = state.position.load(Ordering::Relaxed); + for frame in output.chunks_mut(state.channels as usize) { + if pos >= state.buffer.len() { + for s in frame.iter_mut() { + *s = 0.0; + } + continue; + } + let src_channels = state.channels as usize; + for (i, s) in frame.iter_mut().enumerate() { + let src_idx = pos + (i % src_channels); + *s = if src_idx < state.buffer.len() { + state.buffer[src_idx] + } else { + 0.0 + }; + } + pos += src_channels; + } + state.position.store(pos, Ordering::Relaxed); +} + +fn read_samples_i16(state: &PlaybackState, output: &mut [i16]) { + let mut pos = state.position.load(Ordering::Relaxed); + for frame in output.chunks_mut(state.channels as usize) { + if pos >= state.buffer.len() { + for s in frame.iter_mut() { + *s = 0; + } + continue; + } + let src_channels = state.channels as usize; + for (i, s) in frame.iter_mut().enumerate() { + let src_idx = pos + (i % src_channels); + *s = if src_idx < state.buffer.len() { + (state.buffer[src_idx] * 32767.0) as i16 + } else { + 0 + }; + } + pos += src_channels; + } + state.position.store(pos, Ordering::Relaxed); +} + +fn read_samples_u16(state: &PlaybackState, output: &mut [u16]) { + let mut pos = state.position.load(Ordering::Relaxed); + for frame in output.chunks_mut(state.channels as usize) { + if pos >= state.buffer.len() { + for s in frame.iter_mut() { + *s = 32768; + } + continue; + } + let src_channels = state.channels as usize; + for (i, s) in frame.iter_mut().enumerate() { + let src_idx = pos + (i % src_channels); + *s = if src_idx < state.buffer.len() { + ((state.buffer[src_idx] + 1.0) * 32767.0) as u16 + } else { + 32768 + }; + } + pos += src_channels; + } + state.position.store(pos, Ordering::Relaxed); +} + +/// Play a single sound spec and block until it finishes. +pub fn play_spec_blocking(spec: &soundgen_fmt::SoundSpec) -> Result<(), String> { + let samples = soundgen_fmt::render_spec(spec); + let player = AudioPlayer::play(&samples, spec.sample_rate)?; + while !player.is_finished() { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Ok(()) +} diff --git a/crates/soundgen-io/src/wav.rs b/crates/soundgen-io/src/wav.rs new file mode 100644 index 0000000..38efb94 --- /dev/null +++ b/crates/soundgen-io/src/wav.rs @@ -0,0 +1,146 @@ +//! WAV file writer — converts `Vec` samples to WAV files. + +use hound::{SampleFormat, WavSpec, WavWriter}; +use std::path::Path; + +/// Write interleaved stereo samples to a 16-bit WAV file. +/// +/// `samples` is interleaved: [L, R, L, R, ...]. +pub fn write_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> { + write_wav_bits(path, samples, sample_rate, 16) +} + +/// Write interleaved stereo samples to a WAV file with specified bit depth (16 or 24). +pub fn write_wav_bits( + path: &Path, + samples: &[f32], + sample_rate: u32, + bits_per_sample: u16, +) -> Result<(), String> { + let spec = WavSpec { + channels: 2, + sample_rate, + bits_per_sample, + sample_format: SampleFormat::Int, + }; + + let mut writer = WavWriter::create(path, spec).map_err(|e| format!("create WAV: {}", e))?; + + let max_val = (1 << (bits_per_sample - 1)) - 1; + + for &sample in samples { + let clamped = sample.clamp(-1.0, 1.0); + let int_sample = (clamped * max_val as f32) as i32; + if bits_per_sample == 24 { + writer + .write_sample::(int_sample) + .map_err(|e| format!("write sample: {}", e))?; + } else { + writer + .write_sample::(int_sample as i16) + .map_err(|e| format!("write sample: {}", e))?; + } + } + + writer + .finalize() + .map_err(|e| format!("finalize WAV: {}", e))?; + Ok(()) +} + +/// Write mono samples to a WAV file. +pub fn write_wav_mono(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> { + write_wav_mono_bits(path, samples, sample_rate, 16) +} + +pub fn write_wav_mono_bits( + path: &Path, + samples: &[f32], + sample_rate: u32, + bits_per_sample: u16, +) -> Result<(), String> { + let spec = WavSpec { + channels: 1, + sample_rate, + bits_per_sample, + sample_format: SampleFormat::Int, + }; + + let mut writer = WavWriter::create(path, spec).map_err(|e| format!("create WAV: {}", e))?; + + let max_val = (1 << (bits_per_sample - 1)) - 1; + + for &sample in samples { + let clamped = sample.clamp(-1.0, 1.0); + let int_sample = (clamped * max_val as f32) as i32; + if bits_per_sample == 24 { + writer + .write_sample::(int_sample) + .map_err(|e| format!("write sample: {}", e))?; + } else { + writer + .write_sample::(int_sample as i16) + .map_err(|e| format!("write sample: {}", e))?; + } + } + + writer + .finalize() + .map_err(|e| format!("finalize WAV: {}", e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_write_wav_stereo() { + let path = std::env::temp_dir().join("soundgen_test_stereo.wav"); + let samples: Vec = (0..88200) + .map(|i| { + let t = i as f32 / 44100.0; + (t * 440.0 * 2.0 * std::f32::consts::PI).sin() * 0.5 + }) + .collect(); + // Interleave: mono → stereo by duplicating + let stereo: Vec = samples.iter().flat_map(|&s| [s, s]).collect(); + write_wav(&path, &stereo, 44100).unwrap(); + assert!(path.exists()); + + // Verify by reading back + let reader = hound::WavReader::open(&path).unwrap(); + let spec = reader.spec(); + assert_eq!(spec.channels, 2); + assert_eq!(spec.sample_rate, 44100); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_write_wav_mono() { + let path = std::env::temp_dir().join("soundgen_test_mono.wav"); + let samples: Vec = (0..44100) + .map(|i| { + let t = i as f32 / 44100.0; + (t * 220.0 * 2.0 * std::f32::consts::PI).sin() * 0.5 + }) + .collect(); + write_wav_mono(&path, &samples, 44100).unwrap(); + assert!(path.exists()); + + let reader = hound::WavReader::open(&path).unwrap(); + assert_eq!(reader.spec().channels, 1); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_write_wav_clamps() { + let path = std::env::temp_dir().join("soundgen_test_clamp.wav"); + let samples = vec![2.0, -2.0, 1.0, -1.0, 0.0]; // out of range + write_wav_mono(&path, &samples, 44100).unwrap(); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/soundgen-mcp/Cargo.toml b/crates/soundgen-mcp/Cargo.toml new file mode 100644 index 0000000..33d3536 --- /dev/null +++ b/crates/soundgen-mcp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "soundgen-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "soundgen-mcp" +path = "src/bin/mcp.rs" + +[dependencies] +soundgen-core.workspace = true +soundgen-fmt.workspace = true +soundgen-io.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/soundgen-mcp/src/bin/mcp.rs b/crates/soundgen-mcp/src/bin/mcp.rs new file mode 100644 index 0000000..602bcee --- /dev/null +++ b/crates/soundgen-mcp/src/bin/mcp.rs @@ -0,0 +1,48 @@ +//! MCP server binary — runs soundgen as an MCP tool server on stdio. +//! +//! Usage: soundgen-mcp [--presets-dir ] +//! +//! Configure in your MCP client (e.g., Claude Desktop) as: +//! ```json +//! { +//! "mcpServers": { +//! "soundgen": { +//! "command": "soundgen-mcp", +//! "args": ["--presets-dir", "/path/to/presets"] +//! } +//! } +//! } +//! ``` + +use std::path::PathBuf; + +fn main() { + let mut presets_dir = PathBuf::from("presets"); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--presets-dir" => { + if let Some(dir) = args.next() { + presets_dir = PathBuf::from(dir); + } + } + "--help" | "-h" => { + eprintln!("soundgen-mcp: MCP server for 8-bit sound generation"); + eprintln!("Usage: soundgen-mcp [--presets-dir ]"); + eprintln!(); + eprintln!("Tools exposed:"); + eprintln!(" list_presets - List available sound presets"); + eprintln!(" generate_sfx - Generate WAV from a named preset"); + eprintln!(" render_sound - Render WAV from a SoundSpec JSON"); + std::process::exit(0); + } + _ => {} + } + } + + if let Err(e) = soundgen_mcp::run_server(&presets_dir) { + eprintln!("soundgen-mcp error: {}", e); + std::process::exit(1); + } +} diff --git a/crates/soundgen-mcp/src/lib.rs b/crates/soundgen-mcp/src/lib.rs new file mode 100644 index 0000000..b3aab7e --- /dev/null +++ b/crates/soundgen-mcp/src/lib.rs @@ -0,0 +1,11 @@ +//! MCP server for LLM integration. +//! +//! Exposes three tools over stdio (JSON-RPC 2.0 / MCP protocol): +//! - `list_presets`: list available sound presets +//! - `generate_sfx`: generate a WAV from a named preset +//! - `render_sound`: render a WAV from a SoundSpec JSON object + +pub mod server; +pub mod tools; + +pub use server::run_server; diff --git a/crates/soundgen-mcp/src/server.rs b/crates/soundgen-mcp/src/server.rs new file mode 100644 index 0000000..31046bd --- /dev/null +++ b/crates/soundgen-mcp/src/server.rs @@ -0,0 +1,157 @@ +//! MCP server — JSON-RPC 2.0 over stdio. +//! +//! Implements the Model Context Protocol for tool exposure to LLMs. +//! Reads JSON-RPC requests from stdin, writes responses to stdout. + +use std::io::{self, BufRead, Write}; + +use soundgen_fmt::PresetRegistry; + +use crate::tools; + +/// Run the MCP server on stdio. Blocks until stdin is closed. +pub fn run_server(presets_dir: &std::path::Path) -> io::Result<()> { + let registry = PresetRegistry::load_dir(presets_dir).unwrap_or_else(|e| { + eprintln!( + "soundgen-mcp: warning: could not load presets from {}: {}", + presets_dir.display(), + e + ); + PresetRegistry::new() + }); + + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + if line.trim().is_empty() { + continue; + } + + let request: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let method = request.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let id = request + .get("id") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let response = match method { + "initialize" => handle_initialize(&id), + "notifications/initialized" => serde_json::Value::Null, // notification, no response + "tools/list" => handle_tools_list(&id), + "tools/call" => handle_tools_call(&id, &request, ®istry), + _ => { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("Method not found: {}", method) + } + }) + } + }; + + if response != serde_json::Value::Null { + writeln!(stdout, "{}", response)?; + stdout.flush()?; + } + } + + Ok(()) +} + +fn handle_initialize(id: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "soundgen", + "version": env!("CARGO_PKG_VERSION") + } + } + }) +} + +fn handle_tools_list(id: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": tools::tool_definitions() + } + }) +} + +fn handle_tools_call( + id: &serde_json::Value, + request: &serde_json::Value, + registry: &PresetRegistry, +) -> serde_json::Value { + let params = request.get("params").unwrap_or(&serde_json::Value::Null); + let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let arguments = params.get("arguments").unwrap_or(&serde_json::Value::Null); + + let result = match tool_name { + "list_presets" => { + let category = arguments.get("category").and_then(|c| c.as_str()); + tools::list_presets(registry, category) + } + "generate_sfx" => { + let preset = arguments + .get("preset") + .and_then(|p| p.as_str()) + .unwrap_or(""); + let out_path = arguments + .get("out_path") + .and_then(|p| p.as_str()) + .unwrap_or(""); + let volume = arguments + .get("volume") + .and_then(|v| v.as_f64()) + .map(|v| v as f32); + let duration = arguments + .get("duration") + .and_then(|v| v.as_f64()) + .map(|v| v as f32); + tools::generate_sfx(registry, preset, out_path, volume, duration) + } + "render_sound" => { + let spec = arguments.get("spec").unwrap_or(&serde_json::Value::Null); + let spec_json = serde_json::to_string(spec).unwrap_or_default(); + let out_path = arguments + .get("out_path") + .and_then(|p| p.as_str()) + .unwrap_or(""); + tools::render_sound(&spec_json, out_path) + } + _ => tools::ToolResult::err(format!("Unknown tool: {}", tool_name)), + }; + + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": result.text + }], + "isError": result.is_error + } + }) +} diff --git a/crates/soundgen-mcp/src/tools.rs b/crates/soundgen-mcp/src/tools.rs new file mode 100644 index 0000000..be2c9a6 --- /dev/null +++ b/crates/soundgen-mcp/src/tools.rs @@ -0,0 +1,205 @@ +//! Tool implementations for the MCP server. + +use soundgen_fmt::{render_spec, PresetCategory, PresetRegistry, SoundSpec}; +use soundgen_io::write_wav; +use std::path::Path; + +/// Result of a tool call. +pub struct ToolResult { + pub text: String, + pub is_error: bool, +} + +impl ToolResult { + pub fn ok(text: String) -> Self { + Self { + text, + is_error: false, + } + } + pub fn err(text: String) -> Self { + Self { + text, + is_error: true, + } + } +} + +/// List available presets, optionally filtered by category. +pub fn list_presets(registry: &PresetRegistry, category: Option<&str>) -> ToolResult { + let cat = category.and_then(PresetCategory::from_str); + let presets = registry.list(cat); + + if presets.is_empty() { + return ToolResult::ok("No presets available.".to_string()); + } + + let mut text = format!("Available presets ({}):\n\n", presets.len()); + text.push_str(&format!( + "{:<20} {:<10} {:<10} {}\n", + "NAME", "CATEGORY", "DURATION", "CHANNELS" + )); + text.push_str(&"-".repeat(60)); + text.push('\n'); + + for p in &presets { + text.push_str(&format!( + "{:<20} {:<10} {:<10.2} {}\n", + p.name, + p.category.as_str(), + p.spec.duration, + p.spec.channels.len() + )); + } + + text.push_str("\nUse 'generate_sfx' with a preset name to generate a WAV file."); + + ToolResult::ok(text) +} + +/// Generate a WAV file from a named preset. +pub fn generate_sfx( + registry: &PresetRegistry, + preset_name: &str, + out_path: &str, + volume_override: Option, + duration_override: Option, +) -> ToolResult { + let entry = match registry.get(preset_name) { + Some(e) => e, + None => { + let names: Vec = registry.names(); + return ToolResult::err(format!( + "Preset '{}' not found. Available: {}", + preset_name, + names.join(", ") + )); + } + }; + + let mut spec = entry.spec.clone(); + + if let Some(vol) = volume_override { + for ch in &mut spec.channels { + match ch { + soundgen_fmt::ChannelSpec::Pulse { volume, .. } => *volume = vol, + soundgen_fmt::ChannelSpec::Triangle { volume, .. } => *volume = vol, + soundgen_fmt::ChannelSpec::Noise { volume, .. } => *volume = vol, + } + } + } + + if let Some(dur) = duration_override { + spec.duration = dur; + } + + let samples = render_spec(&spec); + let path = Path::new(out_path); + + if let Err(e) = write_wav(path, &samples, spec.sample_rate) { + return ToolResult::err(format!("Failed to write WAV: {}", e)); + } + + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + ToolResult::ok(format!( + "Generated '{}' → {} ({} samples, {:.2}s, {} Hz, {} bytes)", + preset_name, + out_path, + samples.len() / 2, + spec.duration, + spec.sample_rate, + file_size + )) +} + +/// Render a WAV file from a SoundSpec JSON object. +pub fn render_sound(spec_json: &str, out_path: &str) -> ToolResult { + let spec: SoundSpec = match serde_json::from_str(spec_json) { + Ok(s) => s, + Err(e) => return ToolResult::err(format!("Invalid SoundSpec JSON: {}", e)), + }; + + let samples = render_spec(&spec); + let path = Path::new(out_path); + + if let Err(e) = write_wav(path, &samples, spec.sample_rate) { + return ToolResult::err(format!("Failed to write WAV: {}", e)); + } + + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + ToolResult::ok(format!( + "Rendered '{}' → {} ({} samples, {:.2}s, {} Hz, {} bytes)", + spec.name, + out_path, + samples.len() / 2, + spec.duration, + spec.sample_rate, + file_size + )) +} + +/// Tool definitions for MCP protocol. +pub fn tool_definitions() -> Vec { + vec![ + serde_json::json!({ + "name": "list_presets", + "description": "List available sound presets. Returns preset names, categories, durations, and channel counts.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Filter by category: 'sfx', 'ui', or 'ambient'. If omitted, lists all.", + "enum": ["sfx", "ui", "ambient"] + } + } + } + }), + serde_json::json!({ + "name": "generate_sfx", + "description": "Generate a WAV sound file from a named preset. The preset must exist in the presets directory.", + "inputSchema": { + "type": "object", + "properties": { + "preset": { + "type": "string", + "description": "Preset name (e.g., 'jump', 'explosion', 'coin', 'click')" + }, + "out_path": { + "type": "string", + "description": "Output WAV file path" + }, + "volume": { + "type": "number", + "description": "Override volume (0.0-1.0). Optional." + }, + "duration": { + "type": "number", + "description": "Override duration in seconds. Optional." + } + }, + "required": ["preset", "out_path"] + } + }), + serde_json::json!({ + "name": "render_sound", + "description": "Render a WAV file from a custom SoundSpec JSON object. Use this to create sounds that don't match any preset.", + "inputSchema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "description": "SoundSpec JSON object with name, duration, sample_rate, and channels array" + }, + "out_path": { + "type": "string", + "description": "Output WAV file path" + } + }, + "required": ["spec", "out_path"] + } + }), + ] +} diff --git a/crates/soundgen-runtime/Cargo.toml b/crates/soundgen-runtime/Cargo.toml new file mode 100644 index 0000000..895925b --- /dev/null +++ b/crates/soundgen-runtime/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "soundgen-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +soundgen-core.workspace = true +soundgen-fmt.workspace = true + +[dev-dependencies] +soundgen-io.workspace = true diff --git a/crates/soundgen-runtime/src/bank.rs b/crates/soundgen-runtime/src/bank.rs new file mode 100644 index 0000000..869d397 --- /dev/null +++ b/crates/soundgen-runtime/src/bank.rs @@ -0,0 +1,216 @@ +//! Sound bank — pre-loaded sound presets for runtime playback. +//! +//! Designed for game integration: load all sounds at init time, +//! then play by name with no allocations. + +use std::collections::HashMap; +use std::path::Path; + +use soundgen_fmt::{PresetRegistry, SoundSpec}; + +/// Pre-rendered sound entry. +struct SoundEntry { + samples: Vec, + sample_rate: u32, +} + +/// A sound bank that pre-renders all sounds at load time. +/// Playback is zero-allocation: just returns a reference to the buffer. +pub struct SoundBank { + sounds: HashMap, +} + +impl SoundBank { + /// Create an empty bank. + pub fn new() -> Self { + Self { + sounds: HashMap::new(), + } + } + + /// Load and pre-render all presets from a directory. + pub fn load_dir(presets_dir: &Path) -> Result { + let registry = PresetRegistry::load_dir(presets_dir)?; + let mut bank = Self::new(); + for entry in registry.list(None) { + let samples = soundgen_fmt::render_spec(&entry.spec); + bank.sounds.insert( + entry.name.to_lowercase(), + SoundEntry { + samples, + sample_rate: entry.spec.sample_rate, + }, + ); + } + Ok(bank) + } + + /// Add a single sound spec to the bank (pre-renders it). + pub fn add(&mut self, name: &str, spec: &SoundSpec) { + let samples = soundgen_fmt::render_spec(spec); + self.sounds.insert( + name.to_lowercase(), + SoundEntry { + samples, + sample_rate: spec.sample_rate, + }, + ); + } + + /// Get pre-rendered samples for a sound (case-insensitive). + pub fn get(&self, name: &str) -> Option<(&[f32], u32)> { + self.sounds + .get(&name.to_lowercase()) + .map(|e| (e.samples.as_slice(), e.sample_rate)) + } + + /// Get a sound with pitch shifting (returns owned Vec). + /// `pitch_ratio` of 1.0 = original, 2.0 = one octave up, 0.5 = one octave down. + pub fn get_pitched(&self, name: &str, pitch_ratio: f32) -> Option<(Vec, u32)> { + let (samples, sr) = self.get(name)?; + if pitch_ratio == 1.0 { + return Some((samples.to_vec(), sr)); + } + + // Simple resampling via linear interpolation + let new_len = (samples.len() as f32 / pitch_ratio) as usize; + let mut result = Vec::with_capacity(new_len); + for i in 0..new_len { + let src_pos = i as f32 * pitch_ratio; + let idx0 = src_pos as usize; + let idx1 = (idx0 + 1).min(samples.len() - 1); + let frac = src_pos - idx0 as f32; + let sample = samples[idx0] * (1.0 - frac) + samples[idx1] * frac; + result.push(sample); + } + Some((result, sr)) + } + + /// Get a sound with volume scaling (returns owned Vec). + pub fn get_with_volume(&self, name: &str, volume: f32) -> Option<(Vec, u32)> { + let (samples, sr) = self.get(name)?; + Some((samples.iter().map(|&s| s * volume).collect(), sr)) + } + + /// List all sound names in the bank. + pub fn names(&self) -> Vec { + self.sounds.keys().cloned().collect() + } + + /// Number of sounds in the bank. + pub fn len(&self) -> usize { + self.sounds.len() + } + + /// Is the bank empty? + pub fn is_empty(&self) -> bool { + self.sounds.is_empty() + } +} + +impl Default for SoundBank { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soundgen_core::FrequencyAutomation; + use soundgen_fmt::ChannelSpec; + + #[test] + fn test_bank_add_and_get() { + let mut bank = SoundBank::new(); + let spec = SoundSpec { + name: "test".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + bank.add("test", &spec); + + assert!(bank.get("test").is_some()); + assert!(bank.get("TEST").is_some()); // case-insensitive + assert!(bank.get("nonexistent").is_none()); + assert_eq!(bank.len(), 1); + } + + #[test] + fn test_bank_pitch_shift() { + let mut bank = SoundBank::new(); + let spec = SoundSpec { + name: "test".to_string(), + duration: 0.1, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 0.5, + pan: 0.0, + }], + }; + bank.add("test", &spec); + + let (orig, _) = bank.get("test").unwrap(); + let (pitched, _) = bank.get_pitched("test", 2.0).unwrap(); + + // Pitched up 2x should be roughly half the length + assert!(pitched.len() < orig.len()); + assert!((pitched.len() as f32 - orig.len() as f32 / 2.0).abs() < 10.0); + } + + #[test] + fn test_bank_volume() { + let mut bank = SoundBank::new(); + let spec = SoundSpec { + name: "test".to_string(), + duration: 0.05, + sample_rate: 44100, + channels: vec![ChannelSpec::Pulse { + duty: 50, + frequency: FrequencyAutomation::fixed(440.0), + envelope: None, + filter: None, + volume: 1.0, + pan: 0.0, + }], + }; + bank.add("test", &spec); + + let (orig, _) = bank.get("test").unwrap(); + let (quiet, _) = bank.get_with_volume("test", 0.5).unwrap(); + + let orig_max = orig.iter().cloned().fold(0.0f32, f32::max); + let quiet_max = quiet.iter().cloned().fold(0.0f32, f32::max); + assert!( + quiet_max < orig_max, + "volume scaling should reduce amplitude" + ); + } + + #[test] + fn test_bank_names() { + let mut bank = SoundBank::new(); + let make = |name: &str| SoundSpec { + name: name.to_string(), + duration: 0.05, + sample_rate: 44100, + channels: vec![], + }; + bank.add("alpha", &make("alpha")); + bank.add("beta", &make("beta")); + let names = bank.names(); + assert_eq!(names.len(), 2); + } +} diff --git a/crates/soundgen-runtime/src/dac.rs b/crates/soundgen-runtime/src/dac.rs new file mode 100644 index 0000000..48739c9 --- /dev/null +++ b/crates/soundgen-runtime/src/dac.rs @@ -0,0 +1,195 @@ +//! NES-authentic nonlinear DAC emulation. +//! +//! The NES 2A03 uses a nonlinear DAC. The output voltage is not linear +//! with the digital value. This module emulates that characteristic. +//! +//! Reference: https://www.nesdev.org/wiki/APU_Mixer#Emulation +//! The pulse channels use a nonlinear mix, and the combined output +//! has a characteristic "crunchy" sound. + +/// NES-style nonlinear DAC emulation. +/// +/// The DAC maps linear float values [-1, 1] through a nonlinear curve +/// that mimics the NES 2A03's analog output stage. +pub struct NesDac { + /// Lookup table for the nonlinear transfer function (256 entries). + table: [f32; 256], +} + +impl NesDac { + pub fn new() -> Self { + // Build nonlinear transfer table. + // The NES DAC has a characteristic curve where: + // - Near zero, output is more sensitive (steeper) + // - Near extremes, output compresses (shallower) + // We approximate with a tanh-like curve plus slight asymmetry. + let mut table = [0.0f32; 256]; + for i in 0..256u16 { + let linear = (i as f32 / 127.5) - 1.0; // -1..1 + // Nonlinear transfer: combination of tanh and cubic + let tanh_part = linear.tanh(); + // Add slight asymmetry (NES DAC is not perfectly symmetric) + let asymmetric = 0.05 * linear * linear * linear; + // Quantize to 8-bit levels (NES is 8-bit internally for mixed output) + let quantized = ((tanh_part + asymmetric) * 63.0).round() / 63.0; + table[i as usize] = quantized; + } + Self { table } + } + + /// Process a sample through the nonlinear DAC. + #[inline] + pub fn process(&mut self, input: f32) -> f32 { + let clamped = input.clamp(-1.0, 1.0); + let idx = ((clamped + 1.0) * 127.5) as usize; + self.table[idx.min(255)] + } + + /// Process a buffer in-place. + pub fn process_buffer(&mut self, samples: &mut [f32]) { + for s in samples.iter_mut() { + *s = self.process(*s); + } + } +} + +impl Default for NesDac { + fn default() -> Self { + Self::new() + } +} + +/// NES hardware-accurate channel mixing. +/// +/// The NES mixes channels with specific relative weights: +/// - Pulse 1 & 2: equal weight +/// - Triangle: ~3x quieter than pulse (due to higher impedance) +/// - Noise: same as pulse +/// - DPCM: ~2x quieter than pulse +/// +/// The mix is also nonlinear: the combined output is not a simple sum. +pub struct HardwareMixer { + dac: NesDac, +} + +impl HardwareMixer { + pub fn new() -> Self { + Self { dac: NesDac::new() } + } + + /// Mix NES-style channels with hardware-accurate weights. + /// + /// - `pulse`: combined pulse output (already mixed) + /// - `triangle`: triangle output + /// - `noise`: noise output + /// - `dpcm`: DPCM output + #[inline] + pub fn mix(&mut self, pulse: f32, triangle: f32, noise: f32, dpcm: f32) -> f32 { + // NES mixing: nonlinear combination + // Reference formula from nesdev.org: + // output = 95.88 - (8128 / (pulse_sum + 244)) + // for the pulse+triangle path + // + // Simplified for float [-1, 1]: + let pulse_mix = pulse * 0.4; + let tri_mix = triangle * 0.15; + let noise_mix = noise * 0.4; + let dpcm_mix = dpcm * 0.2; + + let mixed = pulse_mix + tri_mix + noise_mix + dpcm_mix; + self.dac.process(mixed) + } + + /// Process a buffer of pre-mixed samples through the DAC. + pub fn process(&mut self, samples: &mut [f32]) { + self.dac.process_buffer(samples); + } +} + +impl Default for HardwareMixer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dac_nonlinear() { + let mut dac = NesDac::new(); + // Linear input 0.5 should produce different output than 0.5 * 2 of input 0.25 + let out_quarter = dac.process(0.25); + let out_half = dac.process(0.5); + // Nonlinear: 2 * out(0.25) != out(0.5) + assert!( + (2.0 * out_quarter - out_half).abs() > 0.01, + "DAC should be nonlinear: 2*f(0.25)={}, f(0.5)={}", + 2.0 * out_quarter, + out_half + ); + } + + #[test] + fn test_dac_zero() { + let mut dac = NesDac::new(); + let out = dac.process(0.0); + assert!(out.abs() < 0.02, "DAC at zero should be near zero: {}", out); + } + + #[test] + fn test_dac_clamps() { + let mut dac = NesDac::new(); + let out_pos = dac.process(2.0); + let out_neg = dac.process(-2.0); + // Quantized to 63 levels, so max is 1.0 but might be slightly less + assert!(out_pos >= 0.8 && out_pos <= 1.0, "pos: {}", out_pos); + assert!(out_neg <= -0.8 && out_neg >= -1.0, "neg: {}", out_neg); + } + + #[test] + fn test_dac_quantization() { + let mut dac = NesDac::new(); + // Very small changes should be quantized away + let out1 = dac.process(0.001); + let out2 = dac.process(0.002); + assert!( + (out1 - out2).abs() < 0.001, + "Small differences should be quantized" + ); + } + + #[test] + fn test_hardware_mixer() { + let mut mixer = HardwareMixer::new(); + let out = mixer.mix(0.5, 0.3, 0.2, 0.1); + assert!(out.abs() > 0.0); + assert!(out <= 1.0); + } + + #[test] + fn test_hardware_mixer_triangle_quieter() { + let mut mixer = HardwareMixer::new(); + let out_pulse = mixer.mix(1.0, 0.0, 0.0, 0.0); + let out_triangle = mixer.mix(0.0, 1.0, 0.0, 0.0); + // Triangle should be quieter than pulse + assert!( + out_triangle.abs() < out_pulse.abs(), + "Triangle should be quieter: tri={}, pulse={}", + out_triangle, + out_pulse + ); + } + + #[test] + fn test_dac_process_buffer() { + let mut dac = NesDac::new(); + let mut samples = vec![0.0, 0.5, -0.5, 1.0, -1.0]; + dac.process_buffer(&mut samples); + // Should still be in valid range + for s in &samples { + assert!(s.abs() <= 1.0); + } + } +} diff --git a/crates/soundgen-runtime/src/lib.rs b/crates/soundgen-runtime/src/lib.rs new file mode 100644 index 0000000..5fef372 --- /dev/null +++ b/crates/soundgen-runtime/src/lib.rs @@ -0,0 +1,31 @@ +//! Soundgen runtime — embeddable sound generation for games. +//! +//! Designed for use inside a Rust game engine (e.g., Vulkan-based). +//! No I/O dependencies. All sound is generated in-memory. +//! +//! Features: +//! - NES-authentic nonlinear DAC emulation +//! - Hardware-accurate channel mixing +//! - Runtime sound bank: load presets at init, play by name +//! - Pitch shifting for variations +//! - No allocations in playback path + +pub mod bank; +pub mod dac; +pub mod mixer; + +pub use bank::SoundBank; +pub use dac::NesDac; +pub use mixer::HardwareMixer; + +/// Render a SoundSpec to mono samples with NES-authentic DAC. +pub fn render_spec_nes(spec: &soundgen_fmt::SoundSpec) -> Vec { + let stereo = soundgen_fmt::render_spec(spec); + let mut mono = Vec::with_capacity(stereo.len() / 2); + let mut dac = NesDac::new(); + for chunk in stereo.chunks_exact(2) { + let mixed = (chunk[0] + chunk[1]) * 0.5; + mono.push(dac.process(mixed)); + } + mono +} diff --git a/crates/soundgen-runtime/src/mixer.rs b/crates/soundgen-runtime/src/mixer.rs new file mode 100644 index 0000000..e842145 --- /dev/null +++ b/crates/soundgen-runtime/src/mixer.rs @@ -0,0 +1,6 @@ +//! Hardware mixer module — re-exports from dac.rs. +//! +//! The [`HardwareMixer`] is in [`dac`], this module provides +//! a convenience re-export. + +pub use crate::dac::HardwareMixer; diff --git a/crates/soundgen-seq/Cargo.toml b/crates/soundgen-seq/Cargo.toml new file mode 100644 index 0000000..9fcf214 --- /dev/null +++ b/crates/soundgen-seq/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "soundgen-seq" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +soundgen-core.workspace = true +serde.workspace = true +serde_json.workspace = true \ No newline at end of file diff --git a/crates/soundgen-seq/src/lib.rs b/crates/soundgen-seq/src/lib.rs new file mode 100644 index 0000000..f2cfa08 --- /dev/null +++ b/crates/soundgen-seq/src/lib.rs @@ -0,0 +1,238 @@ +//! Sequencer — pattern-based song playback. +//! +//! A [`Song`] contains multiple [`Pattern`]s played in order. +//! Each pattern is a grid of rows; each row has one note per track. + +pub mod sequencer; + +pub use sequencer::render_song; + +use serde::{Deserialize, Serialize}; + +/// A musical note (frequency + velocity). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Note { + pub frequency: f32, + #[serde(default = "default_velocity")] + pub velocity: f32, +} + +fn default_velocity() -> f32 { + 1.0 +} + +/// A row in a pattern: one optional note per track. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Row { + /// `None` = rest, `Some(note)` = note on. + /// Length should match the number of tracks. + #[serde(default)] + pub notes: Vec>, +} + +/// A pattern: a sequence of rows. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Pattern { + #[serde(default)] + pub rows: Vec, +} + +/// Track configuration: voice type + envelope + mix settings. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TrackConfig { + #[serde(flatten)] + pub voice: TrackVoice, + #[serde(default)] + pub envelope: Option, + #[serde(default = "default_volume")] + pub volume: f32, + #[serde(default)] + pub pan: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum TrackVoice { + Pulse { + #[serde(default = "default_duty")] + duty: u8, + }, + Triangle, + Noise { + #[serde(default = "default_noise_mode")] + mode: String, + #[serde(default = "default_noise_freq")] + frequency: f32, + }, + Wavetable, + Fm { + #[serde(default = "default_mod_ratio")] + mod_ratio: f32, + #[serde(default = "default_mod_index")] + mod_index: f32, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EnvelopeConfig { + #[serde(default)] + pub attack: f32, + #[serde(default)] + pub decay: f32, + #[serde(default = "default_sustain")] + pub sustain: f32, + #[serde(default)] + pub release: f32, +} + +/// A complete song: patterns, track configs, and playback order. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Song { + pub bpm: f32, + #[serde(default = "default_rows_per_beat")] + pub rows_per_beat: u32, + #[serde(default = "default_sample_rate")] + pub sample_rate: u32, + pub tracks: Vec, + pub patterns: Vec, + /// Order of patterns to play (indices into `patterns`). + #[serde(default)] + pub pattern_order: Vec, +} + +fn default_volume() -> f32 { + 0.6 +} +fn default_duty() -> u8 { + 50 +} +fn default_noise_mode() -> String { + "white".to_string() +} +fn default_noise_freq() -> f32 { + 8000.0 +} +fn default_mod_ratio() -> f32 { + 2.0 +} +fn default_mod_index() -> f32 { + 1.0 +} +fn default_sustain() -> f32 { + 0.6 +} +fn default_rows_per_beat() -> u32 { + 4 +} +fn default_sample_rate() -> u32 { + 44100 +} + +impl Note { + /// Create a note from a MIDI note number. + pub fn from_midi(midi: u8, velocity: f32) -> Self { + let freq = 440.0 * 2.0f32.powf((midi as f32 - 69.0) / 12.0); + Self { + frequency: freq, + velocity, + } + } + + /// Create a note from a note name (e.g., "A4", "C#5"). + pub fn from_name(name: &str, velocity: f32) -> Option { + let note_names = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let note_part = &name[..name.len() - 1]; + let octave: i32 = name[name.len() - 1..].parse().ok()?; + let semitone = note_names.iter().position(|&n| n == note_part)? as i32; + let midi = 12 * (octave + 1) + semitone; + Some(Self::from_midi(midi as u8, velocity)) + } +} + +impl Song { + /// Total duration in seconds. + pub fn duration(&self) -> f32 { + let total_rows: usize = self + .pattern_order + .iter() + .filter_map(|&i| self.patterns.get(i).map(|p| p.rows.len())) + .sum(); + let seconds_per_row = 60.0 / self.bpm / self.rows_per_beat as f32; + total_rows as f32 * seconds_per_row + } + + /// Total number of rows across all patterns in order. + pub fn total_rows(&self) -> usize { + self.pattern_order + .iter() + .filter_map(|&i| self.patterns.get(i).map(|p| p.rows.len())) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_note_from_midi() { + let n = Note::from_midi(69, 1.0); // A4 = 440 Hz + assert!((n.frequency - 440.0).abs() < 0.1); + } + + #[test] + fn test_note_from_name() { + let n = Note::from_name("A4", 1.0).unwrap(); + assert!((n.frequency - 440.0).abs() < 0.1); + + let n = Note::from_name("C4", 0.8).unwrap(); + assert!((n.frequency - 261.63).abs() < 0.5); + + let n = Note::from_name("C#4", 0.8).unwrap(); + assert!((n.frequency - 277.18).abs() < 0.5); + } + + #[test] + fn test_song_duration() { + let song = Song { + bpm: 120.0, + rows_per_beat: 4, + sample_rate: 44100, + tracks: vec![], + patterns: vec![Pattern { + rows: vec![Row { notes: vec![] }; 16], + }], + pattern_order: vec![0], + }; + // 16 rows, 4 rows per beat, 120 bpm → 4 beats → 2 seconds + assert!((song.duration() - 2.0).abs() < 0.01); + } + + #[test] + fn test_song_serde() { + let json = r#"{ + "bpm": 140, + "rows_per_beat": 4, + "tracks": [ + { "type": "pulse", "duty": 50, "volume": 0.5 } + ], + "patterns": [ + { + "rows": [ + { "notes": [{ "frequency": 440, "velocity": 1.0 }] }, + { "notes": [null] } + ] + } + ], + "pattern_order": [0] + }"#; + let song: Song = serde_json::from_str(json).unwrap(); + assert_eq!(song.bpm, 140.0); + assert_eq!(song.tracks.len(), 1); + assert_eq!(song.patterns[0].rows.len(), 2); + assert!(song.patterns[0].rows[0].notes[0].is_some()); + assert!(song.patterns[0].rows[1].notes[0].is_none()); + } +} diff --git a/crates/soundgen-seq/src/sequencer.rs b/crates/soundgen-seq/src/sequencer.rs new file mode 100644 index 0000000..dd47920 --- /dev/null +++ b/crates/soundgen-seq/src/sequencer.rs @@ -0,0 +1,194 @@ +//! Sequencer — renders a [`Song`] to interleaved stereo `Vec`. + +use soundgen_core::generator::Voice; +use soundgen_core::{ + voice::{ + DutyCycle, FmChannel, NoiseChannel, NoiseMode, PulseChannel, TriangleChannel, + WavetableChannel, + }, + ChannelRenderer, Envelope, VoiceKind, +}; + +use crate::{Song, TrackConfig, TrackVoice}; + +/// Render a [`Song`] to interleaved stereo samples (L, R, L, R, ...). +pub fn render_song(song: &Song) -> Vec { + let sr = song.sample_rate as f32; + let seconds_per_row = 60.0 / song.bpm / song.rows_per_beat as f32; + let samples_per_row = (seconds_per_row * sr) as usize; + + // Build channel renderers from track configs + let mut channels: Vec = song + .tracks + .iter() + .map(|tc| build_track_renderer(tc, sr)) + .collect(); + + // Trigger and render + let total_rows = song.total_rows(); + let total_samples = total_rows * samples_per_row; + let mut out = Vec::with_capacity(total_samples * 2); + + for &pattern_idx in &song.pattern_order { + let pattern = match song.patterns.get(pattern_idx) { + Some(p) => p, + None => continue, + }; + + for row in &pattern.rows { + // Trigger notes for this row + for (track_idx, note_opt) in row.notes.iter().enumerate() { + if track_idx >= channels.len() { + break; + } + if let Some(note) = note_opt { + channels[track_idx].trigger(); + channels[track_idx].set_frequency(note.frequency); + } + } + + // Render this row's worth of samples + for _ in 0..samples_per_row { + let mut left = 0.0f32; + let mut right = 0.0f32; + for ch in &mut channels { + let (l, r) = ch.tick(); + left += l; + right += r; + } + out.push(left.tanh()); + out.push(right.tanh()); + } + } + } + + out +} + +fn build_track_renderer(tc: &TrackConfig, sr: f32) -> ChannelRenderer { + let voice = build_voice(&tc.voice, sr); + let mut cr = ChannelRenderer::new(voice, sr) + .with_volume(tc.volume) + .with_pan(tc.pan); + + if let Some(env) = &tc.envelope { + cr = cr.with_envelope(Envelope::adsr( + sr, + env.attack, + env.decay, + env.sustain, + env.release, + )); + } + + cr +} + +fn build_voice(tv: &TrackVoice, sr: f32) -> VoiceKind { + match tv { + TrackVoice::Pulse { duty } => { + VoiceKind::Pulse(PulseChannel::new(sr, DutyCycle::from_percent(*duty))) + } + TrackVoice::Triangle => VoiceKind::Triangle(TriangleChannel::new(sr)), + TrackVoice::Noise { mode, frequency } => { + let nm = match mode.as_str() { + "periodic" => NoiseMode::Periodic, + _ => NoiseMode::White, + }; + let mut noise = NoiseChannel::new(sr, nm); + noise.note_on(*frequency, 1.0); + VoiceKind::Noise(noise) + } + TrackVoice::Wavetable => VoiceKind::Wavetable(WavetableChannel::new(sr)), + TrackVoice::Fm { + mod_ratio, + mod_index, + } => { + let mut fm = FmChannel::new(sr); + fm.set_mod_ratio(*mod_ratio); + fm.set_mod_index(*mod_index); + VoiceKind::Fm(fm) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Note, Pattern, Row, Song, TrackConfig, TrackVoice}; + + #[test] + fn test_render_simple_song() { + let song = Song { + bpm: 120.0, + rows_per_beat: 4, + sample_rate: 44100, + tracks: vec![TrackConfig { + voice: TrackVoice::Pulse { duty: 50 }, + envelope: None, + volume: 0.5, + pan: 0.0, + }], + patterns: vec![Pattern { + rows: vec![ + Row { + notes: vec![Some(Note::from_name("C4", 1.0).unwrap())], + }, + Row { notes: vec![None] }, + Row { + notes: vec![Some(Note::from_name("E4", 1.0).unwrap())], + }, + Row { notes: vec![None] }, + ], + }], + pattern_order: vec![0], + }; + + let out = render_song(&song); + // 4 rows, 0.125s per row, 44100 Hz → 5512.5 samples per row → 22050 frames → 44100 interleaved + assert!(!out.is_empty()); + // Should have some non-zero signal + let non_zero = out.iter().filter(|&&s| s.abs() > 0.001).count(); + assert!(non_zero > 100, "expected non-zero output, got {}", non_zero); + } + + #[test] + fn test_render_multi_track_song() { + let song = Song { + bpm: 100.0, + rows_per_beat: 4, + sample_rate: 22050, + tracks: vec![ + TrackConfig { + voice: TrackVoice::Pulse { duty: 50 }, + envelope: None, + volume: 0.4, + pan: -0.3, + }, + TrackConfig { + voice: TrackVoice::Triangle, + envelope: None, + volume: 0.5, + pan: 0.3, + }, + ], + patterns: vec![Pattern { + rows: vec![ + Row { + notes: vec![ + Some(Note::from_name("A4", 1.0).unwrap()), + Some(Note::from_name("A2", 1.0).unwrap()), + ], + }, + Row { + notes: vec![None, None], + }, + ], + }], + pattern_order: vec![0], + }; + + let out = render_song(&song); + assert!(!out.is_empty()); + } +} diff --git a/presets/ambient/drone.json b/presets/ambient/drone.json new file mode 100644 index 0000000..91b01d6 --- /dev/null +++ b/presets/ambient/drone.json @@ -0,0 +1,29 @@ +{ + "name": "drone", + "duration": 5.0, + "sample_rate": 44100, + "channels": [ + { + "type": "triangle", + "frequency": { "start": 55, "end": 55 }, + "envelope": { "attack": 1.0, "decay": 0.0, "sustain": 0.9, "release": 1.0 }, + "volume": 0.4, + "pan": -0.3 + }, + { + "type": "triangle", + "frequency": { "start": 82.5, "end": 82.5 }, + "envelope": { "attack": 1.5, "decay": 0.0, "sustain": 0.7, "release": 1.0 }, + "volume": 0.3, + "pan": 0.3 + }, + { + "type": "pulse", + "duty": 12, + "frequency": { "start": 110, "end": 110 }, + "envelope": { "attack": 2.0, "decay": 0.0, "sustain": 0.3, "release": 1.0 }, + "volume": 0.15, + "pan": 0.0 + } + ] +} diff --git a/presets/ambient/rain.json b/presets/ambient/rain.json new file mode 100644 index 0000000..e5a200c --- /dev/null +++ b/presets/ambient/rain.json @@ -0,0 +1,33 @@ +{ + "name": "rain", + "duration": 5.0, + "sample_rate": 44100, + "channels": [ + { + "type": "noise", + "mode": "white", + "frequency": 10000, + "filter": { + "type": "highpass", + "cutoff": 2000, + "q": 0.7 + }, + "envelope": { "attack": 0.5, "decay": 0.0, "sustain": 0.5, "release": 0.5 }, + "volume": 0.15, + "pan": 0.0 + }, + { + "type": "noise", + "mode": "white", + "frequency": 8000, + "filter": { + "type": "lowpass", + "cutoff": 1500, + "q": 0.5 + }, + "envelope": { "attack": 0.8, "decay": 0.0, "sustain": 0.4, "release": 0.5 }, + "volume": 0.1, + "pan": 0.0 + } + ] +} diff --git a/presets/ambient/wind.json b/presets/ambient/wind.json new file mode 100644 index 0000000..cf804cc --- /dev/null +++ b/presets/ambient/wind.json @@ -0,0 +1,33 @@ +{ + "name": "wind", + "duration": 5.0, + "sample_rate": 44100, + "channels": [ + { + "type": "noise", + "mode": "white", + "frequency": 6000, + "filter": { + "type": "lowpass", + "cutoff": 800, + "q": 0.5 + }, + "envelope": { "attack": 1.0, "decay": 0.0, "sustain": 0.8, "release": 1.0 }, + "volume": 0.3, + "pan": -0.2 + }, + { + "type": "noise", + "mode": "white", + "frequency": 4000, + "filter": { + "type": "lowpass", + "cutoff": 400, + "q": 0.5 + }, + "envelope": { "attack": 1.5, "decay": 0.0, "sustain": 0.6, "release": 1.0 }, + "volume": 0.25, + "pan": 0.2 + } + ] +} diff --git a/presets/sfx/coin.json b/presets/sfx/coin.json new file mode 100644 index 0000000..345605d --- /dev/null +++ b/presets/sfx/coin.json @@ -0,0 +1,15 @@ +{ + "name": "coin", + "duration": 0.15, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 800, "end": 1200, "curve": "exponential" }, + "envelope": { "attack": 0.001, "decay": 0.04, "sustain": 0.0, "release": 0.1 }, + "volume": 0.5, + "pan": 0.0 + } + ] +} diff --git a/presets/sfx/explosion.json b/presets/sfx/explosion.json new file mode 100644 index 0000000..e72a223 --- /dev/null +++ b/presets/sfx/explosion.json @@ -0,0 +1,35 @@ +{ + "name": "explosion", + "duration": 0.8, + "sample_rate": 44100, + "channels": [ + { + "type": "noise", + "mode": "white", + "frequency": 12000, + "filter": { + "type": "lowpass", + "cutoff": 3000, + "cutoff_sweep": { "start": 3000, "end": 100, "curve": "exponential" }, + "q": 0.7 + }, + "envelope": { "attack": 0.002, "decay": 0.6, "sustain": 0.0, "release": 0.18 }, + "volume": 0.85, + "pan": 0.0 + }, + { + "type": "noise", + "mode": "white", + "frequency": 4000, + "filter": { + "type": "lowpass", + "cutoff": 800, + "cutoff_sweep": { "start": 800, "end": 50, "curve": "exponential" }, + "q": 1.0 + }, + "envelope": { "attack": 0.0, "decay": 0.4, "sustain": 0.0, "release": 0.1 }, + "volume": 0.5, + "pan": 0.0 + } + ] +} diff --git a/presets/sfx/hit.json b/presets/sfx/hit.json new file mode 100644 index 0000000..42b2712 --- /dev/null +++ b/presets/sfx/hit.json @@ -0,0 +1,28 @@ +{ + "name": "hit", + "duration": 0.12, + "sample_rate": 44100, + "channels": [ + { + "type": "noise", + "mode": "white", + "frequency": 6000, + "filter": { + "type": "lowpass", + "cutoff": 1500, + "q": 1.5 + }, + "envelope": { "attack": 0.0, "decay": 0.02, "sustain": 0.0, "release": 0.08 }, + "volume": 0.7, + "pan": 0.0 + }, + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 50, "curve": "exponential" }, + "envelope": { "attack": 0.0, "decay": 0.03, "sustain": 0.0, "release": 0.06 }, + "volume": 0.4, + "pan": 0.0 + } + ] +} diff --git a/presets/sfx/jump.json b/presets/sfx/jump.json new file mode 100644 index 0000000..d089c50 --- /dev/null +++ b/presets/sfx/jump.json @@ -0,0 +1,15 @@ +{ + "name": "jump", + "duration": 0.25, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 150, "end": 600, "curve": "exponential" }, + "envelope": { "attack": 0.005, "decay": 0.08, "sustain": 0.0, "release": 0.15 }, + "volume": 0.6, + "pan": 0.0 + } + ] +} diff --git a/presets/sfx/laser.json b/presets/sfx/laser.json new file mode 100644 index 0000000..8bc14c6 --- /dev/null +++ b/presets/sfx/laser.json @@ -0,0 +1,20 @@ +{ + "name": "laser", + "duration": 0.3, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 25, + "frequency": { "start": 1200, "end": 80, "curve": "exponential" }, + "envelope": { "attack": 0.001, "decay": 0.1, "sustain": 0.0, "release": 0.18 }, + "filter": { + "type": "lowpass", + "cutoff": 4000, + "q": 2.0 + }, + "volume": 0.55, + "pan": 0.0 + } + ] +} diff --git a/presets/sfx/powerup.json b/presets/sfx/powerup.json new file mode 100644 index 0000000..c3c4c08 --- /dev/null +++ b/presets/sfx/powerup.json @@ -0,0 +1,22 @@ +{ + "name": "powerup", + "duration": 0.5, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 1600, "curve": "exponential" }, + "envelope": { "attack": 0.01, "decay": 0.2, "sustain": 0.3, "release": 0.25 }, + "volume": 0.5, + "pan": 0.0 + }, + { + "type": "triangle", + "frequency": { "start": 100, "end": 800, "curve": "exponential" }, + "envelope": { "attack": 0.02, "decay": 0.25, "sustain": 0.2, "release": 0.2 }, + "volume": 0.3, + "pan": 0.0 + } + ] +} diff --git a/presets/ui/click.json b/presets/ui/click.json new file mode 100644 index 0000000..5716ca2 --- /dev/null +++ b/presets/ui/click.json @@ -0,0 +1,15 @@ +{ + "name": "click", + "duration": 0.05, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 1000, "end": 1000 }, + "envelope": { "attack": 0.0, "decay": 0.0, "sustain": 0.0, "release": 0.04 }, + "volume": 0.3, + "pan": 0.0 + } + ] +} diff --git a/presets/ui/confirm.json b/presets/ui/confirm.json new file mode 100644 index 0000000..20f896b --- /dev/null +++ b/presets/ui/confirm.json @@ -0,0 +1,15 @@ +{ + "name": "confirm", + "duration": 0.15, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 600, "end": 900, "curve": "exponential" }, + "envelope": { "attack": 0.005, "decay": 0.03, "sustain": 0.0, "release": 0.1 }, + "volume": 0.35, + "pan": 0.0 + } + ] +} diff --git a/presets/ui/error.json b/presets/ui/error.json new file mode 100644 index 0000000..c74a3b0 --- /dev/null +++ b/presets/ui/error.json @@ -0,0 +1,15 @@ +{ + "name": "error", + "duration": 0.2, + "sample_rate": 44100, + "channels": [ + { + "type": "pulse", + "duty": 50, + "frequency": { "start": 200, "end": 150 }, + "envelope": { "attack": 0.005, "decay": 0.05, "sustain": 0.3, "release": 0.12 }, + "volume": 0.4, + "pan": 0.0 + } + ] +} diff --git a/presets/ui/hover.json b/presets/ui/hover.json new file mode 100644 index 0000000..33690a1 --- /dev/null +++ b/presets/ui/hover.json @@ -0,0 +1,14 @@ +{ + "name": "hover", + "duration": 0.04, + "sample_rate": 44100, + "channels": [ + { + "type": "triangle", + "frequency": { "start": 600, "end": 900 }, + "envelope": { "attack": 0.0, "decay": 0.0, "sustain": 0.0, "release": 0.035 }, + "volume": 0.2, + "pan": 0.0 + } + ] +}