Files
Soundgen/crates/soundgen-fmt/src/lib.rs
T
Emil c7d6c40683 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
2026-06-21 22:07:05 +03:00

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);
}
}