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
280 lines
7.7 KiB
Rust
280 lines
7.7 KiB
Rust
//! 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);
|
|
}
|
|
}
|