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
This commit is contained in:
@@ -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
|
||||
+26
@@ -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
|
||||
@@ -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<f32>`.
|
||||
- `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 <preset> --out <path>
|
||||
cargo run --bin soundgen -- render <spec.json> --out <path>
|
||||
cargo run --bin soundgen -- render-song <song.json> --out <path>
|
||||
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.
|
||||
Generated
+4875
File diff suppressed because it is too large
Load Diff
+34
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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 <name> [--param k=v]... --out <path>`, `gen --from <spec.json> --out <path>`, `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<f32>` в тестах без звуковой карты
|
||||
- **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/`, расширяемые без перекомпиляции
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Sweep>,
|
||||
/// Frequency used when there's no sweep (static frequency).
|
||||
initial_frequency: f32,
|
||||
filter: Option<Filter>,
|
||||
filter_sweep: Option<Sweep>,
|
||||
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<f32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Core synthesis engine: generators, effects, mixer.
|
||||
//!
|
||||
//! No I/O. Renders to `Vec<f32>`.
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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<MixerChannel>,
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<f32>,
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f32> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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<ChannelSpec>,
|
||||
}
|
||||
|
||||
/// 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<CutoffAutomation>,
|
||||
#[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<EnvelopeSpec>,
|
||||
#[serde(default)]
|
||||
filter: Option<FilterSpec>,
|
||||
#[serde(default = "default_volume")]
|
||||
volume: f32,
|
||||
#[serde(default)]
|
||||
pan: f32,
|
||||
},
|
||||
Triangle {
|
||||
#[serde(default)]
|
||||
frequency: FrequencyAutomation,
|
||||
#[serde(default)]
|
||||
envelope: Option<EnvelopeSpec>,
|
||||
#[serde(default)]
|
||||
filter: Option<FilterSpec>,
|
||||
#[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<EnvelopeSpec>,
|
||||
#[serde(default)]
|
||||
filter: Option<FilterSpec>,
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<PresetEntry>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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<PresetCategory>) -> Vec<&PresetEntry> {
|
||||
self.presets
|
||||
.iter()
|
||||
.filter(|p| category.map_or(true, |c| p.category == c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List preset names.
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Render a [`SoundSpec`] to interleaved stereo `Vec<f32>`.
|
||||
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<f32> {
|
||||
let sr = spec.sample_rate as f32;
|
||||
let n_samples = (spec.duration * sr).ceil() as usize;
|
||||
let mut channels: Vec<ChannelRenderer> = 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<EnvelopeSpec>,
|
||||
filter: &Option<FilterSpec>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f32> = 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<f32> = 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::<i16>().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<f32> = samples.iter().step_by(2).cloned().collect();
|
||||
let right: Vec<f32> = samples.iter().skip(1).step_by(2).cloned().collect();
|
||||
assert!(rms(&left) > 0.01);
|
||||
assert!(rms(&right) > 0.01);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<SoundSpec>,
|
||||
redo: Vec<SoundSpec>,
|
||||
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<SoundSpec> {
|
||||
if let Some(prev) = self.undo.pop() {
|
||||
self.redo.push(current.clone());
|
||||
Some(prev)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn redo(&mut self, current: &SoundSpec) -> Option<SoundSpec> {
|
||||
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<String>,
|
||||
preview_samples: Vec<f32>,
|
||||
preview_dirty: bool,
|
||||
last_preview_update: Option<Instant>,
|
||||
status: String,
|
||||
status_time: Option<Instant>,
|
||||
playing: Option<Arc<Mutex<soundgen_io::PlaybackHandle>>>,
|
||||
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::<SoundSpec>(&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<usize> = None;
|
||||
let mut to_dup: Option<usize> = None;
|
||||
let mut to_move_up: Option<usize> = None;
|
||||
let mut to_move_down: Option<usize> = 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::<Song>(&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::<Song>(&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<f32>, 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<f32> {
|
||||
// Interleaved stereo → mono
|
||||
let mono: Vec<f32> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<EnvelopeSpec>) -> 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<FilterSpec>) -> 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
|
||||
}
|
||||
@@ -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<u8>,
|
||||
/// 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<u8>, Vec<u8>, 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<u8> = 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<u8> = 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<u8> = 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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<String> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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<Pos2> = 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::<f32>() / 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,
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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<PlaybackHandle, String> {
|
||||
// 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<std::process::Child>,
|
||||
tmp_file: Option<PathBuf>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<PlaybackHandle, String> {
|
||||
let samples = soundgen_fmt::render_spec(spec);
|
||||
play(&samples, spec.sample_rate)
|
||||
}
|
||||
@@ -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<f32>,
|
||||
position: AtomicUsize,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
/// Realtime audio player. Drop to stop playback.
|
||||
pub struct AudioPlayer {
|
||||
_stream: cpal::Stream,
|
||||
state: Arc<PlaybackState>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Play interleaved stereo samples through the default audio device.
|
||||
pub fn play(samples: &[f32], sample_rate: u32) -> Result<Self, String> {
|
||||
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<Self, String> {
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! WAV file writer — converts `Vec<f32>` 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::<i32>(int_sample)
|
||||
.map_err(|e| format!("write sample: {}", e))?;
|
||||
} else {
|
||||
writer
|
||||
.write_sample::<i16>(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::<i32>(int_sample)
|
||||
.map_err(|e| format!("write sample: {}", e))?;
|
||||
} else {
|
||||
writer
|
||||
.write_sample::<i16>(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<f32> = (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<f32> = 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<f32> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
//! MCP server binary — runs soundgen as an MCP tool server on stdio.
|
||||
//!
|
||||
//! Usage: soundgen-mcp [--presets-dir <path>]
|
||||
//!
|
||||
//! 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 <path>]");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<f32>,
|
||||
duration_override: Option<f32>,
|
||||
) -> ToolResult {
|
||||
let entry = match registry.get(preset_name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let names: Vec<String> = 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<serde_json::Value> {
|
||||
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"]
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<f32>,
|
||||
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<String, SoundEntry>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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<f32>, 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<f32>, 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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<Option<Note>>,
|
||||
}
|
||||
|
||||
/// A pattern: a sequence of rows.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Pattern {
|
||||
#[serde(default)]
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
/// 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<EnvelopeConfig>,
|
||||
#[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<TrackConfig>,
|
||||
pub patterns: Vec<Pattern>,
|
||||
/// Order of patterns to play (indices into `patterns`).
|
||||
#[serde(default)]
|
||||
pub pattern_order: Vec<usize>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Sequencer — renders a [`Song`] to interleaved stereo `Vec<f32>`.
|
||||
|
||||
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<f32> {
|
||||
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<ChannelRenderer> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user