From c7d6c40683c624e119902bac02f64b6f10d6d536 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 21 Jun 2026 22:07:05 +0300 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 41 + .gitignore | 26 + AGENTS.md | 50 + Cargo.lock | 4875 ++++++++++++++++++ Cargo.toml | 34 + LICENSE | 21 + PLAN.md | 237 + README.md | 140 + crates/soundgen-cli/Cargo.toml | 20 + crates/soundgen-cli/src/main.rs | 203 + crates/soundgen-core/Cargo.toml | 9 + crates/soundgen-core/src/automation.rs | 91 + crates/soundgen-core/src/channel.rs | 215 + crates/soundgen-core/src/effect/envelope.rs | 214 + crates/soundgen-core/src/effect/filter.rs | 184 + crates/soundgen-core/src/effect/mod.rs | 11 + crates/soundgen-core/src/effect/sweep.rs | 144 + crates/soundgen-core/src/effect/vibrato.rs | 89 + crates/soundgen-core/src/generator.rs | 18 + crates/soundgen-core/src/lib.rs | 20 + crates/soundgen-core/src/mixer.rs | 168 + crates/soundgen-core/src/voice/dpcm.rs | 191 + crates/soundgen-core/src/voice/fm.rs | 137 + crates/soundgen-core/src/voice/mod.rs | 99 + crates/soundgen-core/src/voice/noise.rs | 164 + crates/soundgen-core/src/voice/pulse.rs | 154 + crates/soundgen-core/src/voice/triangle.rs | 108 + crates/soundgen-core/src/voice/wavetable.rs | 128 + crates/soundgen-fmt/Cargo.toml | 15 + crates/soundgen-fmt/examples/generate_sfx.rs | 51 + crates/soundgen-fmt/examples/play_melody.rs | 91 + crates/soundgen-fmt/examples/render_song.rs | 112 + crates/soundgen-fmt/src/lib.rs | 279 + crates/soundgen-fmt/src/preset.rs | 226 + crates/soundgen-fmt/src/renderer.rs | 265 + crates/soundgen-fmt/tests/integration.rs | 236 + crates/soundgen-gui/Cargo.toml | 19 + crates/soundgen-gui/src/app.rs | 912 ++++ crates/soundgen-gui/src/channel_panel.rs | 480 ++ crates/soundgen-gui/src/keyboard.rs | 328 ++ crates/soundgen-gui/src/main.rs | 42 + crates/soundgen-gui/src/preset_browser.rs | 136 + crates/soundgen-gui/src/waveform.rs | 249 + crates/soundgen-io/Cargo.toml | 15 + crates/soundgen-io/src/lib.rs | 14 + crates/soundgen-io/src/player.rs | 104 + crates/soundgen-io/src/realtime.rs | 206 + crates/soundgen-io/src/wav.rs | 146 + crates/soundgen-mcp/Cargo.toml | 16 + crates/soundgen-mcp/src/bin/mcp.rs | 48 + crates/soundgen-mcp/src/lib.rs | 11 + crates/soundgen-mcp/src/server.rs | 157 + crates/soundgen-mcp/src/tools.rs | 205 + crates/soundgen-runtime/Cargo.toml | 12 + crates/soundgen-runtime/src/bank.rs | 216 + crates/soundgen-runtime/src/dac.rs | 195 + crates/soundgen-runtime/src/lib.rs | 31 + crates/soundgen-runtime/src/mixer.rs | 6 + crates/soundgen-seq/Cargo.toml | 10 + crates/soundgen-seq/src/lib.rs | 238 + crates/soundgen-seq/src/sequencer.rs | 194 + presets/ambient/drone.json | 29 + presets/ambient/rain.json | 33 + presets/ambient/wind.json | 33 + presets/sfx/coin.json | 15 + presets/sfx/explosion.json | 35 + presets/sfx/hit.json | 28 + presets/sfx/jump.json | 15 + presets/sfx/laser.json | 20 + presets/sfx/powerup.json | 22 + presets/ui/click.json | 15 + presets/ui/confirm.json | 15 + presets/ui/error.json | 15 + presets/ui/hover.json | 14 + 74 files changed, 13345 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 crates/soundgen-cli/Cargo.toml create mode 100644 crates/soundgen-cli/src/main.rs create mode 100644 crates/soundgen-core/Cargo.toml create mode 100644 crates/soundgen-core/src/automation.rs create mode 100644 crates/soundgen-core/src/channel.rs create mode 100644 crates/soundgen-core/src/effect/envelope.rs create mode 100644 crates/soundgen-core/src/effect/filter.rs create mode 100644 crates/soundgen-core/src/effect/mod.rs create mode 100644 crates/soundgen-core/src/effect/sweep.rs create mode 100644 crates/soundgen-core/src/effect/vibrato.rs create mode 100644 crates/soundgen-core/src/generator.rs create mode 100644 crates/soundgen-core/src/lib.rs create mode 100644 crates/soundgen-core/src/mixer.rs create mode 100644 crates/soundgen-core/src/voice/dpcm.rs create mode 100644 crates/soundgen-core/src/voice/fm.rs create mode 100644 crates/soundgen-core/src/voice/mod.rs create mode 100644 crates/soundgen-core/src/voice/noise.rs create mode 100644 crates/soundgen-core/src/voice/pulse.rs create mode 100644 crates/soundgen-core/src/voice/triangle.rs create mode 100644 crates/soundgen-core/src/voice/wavetable.rs create mode 100644 crates/soundgen-fmt/Cargo.toml create mode 100644 crates/soundgen-fmt/examples/generate_sfx.rs create mode 100644 crates/soundgen-fmt/examples/play_melody.rs create mode 100644 crates/soundgen-fmt/examples/render_song.rs create mode 100644 crates/soundgen-fmt/src/lib.rs create mode 100644 crates/soundgen-fmt/src/preset.rs create mode 100644 crates/soundgen-fmt/src/renderer.rs create mode 100644 crates/soundgen-fmt/tests/integration.rs create mode 100644 crates/soundgen-gui/Cargo.toml create mode 100644 crates/soundgen-gui/src/app.rs create mode 100644 crates/soundgen-gui/src/channel_panel.rs create mode 100644 crates/soundgen-gui/src/keyboard.rs create mode 100644 crates/soundgen-gui/src/main.rs create mode 100644 crates/soundgen-gui/src/preset_browser.rs create mode 100644 crates/soundgen-gui/src/waveform.rs create mode 100644 crates/soundgen-io/Cargo.toml create mode 100644 crates/soundgen-io/src/lib.rs create mode 100644 crates/soundgen-io/src/player.rs create mode 100644 crates/soundgen-io/src/realtime.rs create mode 100644 crates/soundgen-io/src/wav.rs create mode 100644 crates/soundgen-mcp/Cargo.toml create mode 100644 crates/soundgen-mcp/src/bin/mcp.rs create mode 100644 crates/soundgen-mcp/src/lib.rs create mode 100644 crates/soundgen-mcp/src/server.rs create mode 100644 crates/soundgen-mcp/src/tools.rs create mode 100644 crates/soundgen-runtime/Cargo.toml create mode 100644 crates/soundgen-runtime/src/bank.rs create mode 100644 crates/soundgen-runtime/src/dac.rs create mode 100644 crates/soundgen-runtime/src/lib.rs create mode 100644 crates/soundgen-runtime/src/mixer.rs create mode 100644 crates/soundgen-seq/Cargo.toml create mode 100644 crates/soundgen-seq/src/lib.rs create mode 100644 crates/soundgen-seq/src/sequencer.rs create mode 100644 presets/ambient/drone.json create mode 100644 presets/ambient/rain.json create mode 100644 presets/ambient/wind.json create mode 100644 presets/sfx/coin.json create mode 100644 presets/sfx/explosion.json create mode 100644 presets/sfx/hit.json create mode 100644 presets/sfx/jump.json create mode 100644 presets/sfx/laser.json create mode 100644 presets/sfx/powerup.json create mode 100644 presets/ui/click.json create mode 100644 presets/ui/confirm.json create mode 100644 presets/ui/error.json create mode 100644 presets/ui/hover.json 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 + } + ] +}