//! SoundSpec — declarative JSON format for describing sounds. //! //! LLM-friendly: every sound is a JSON object that can be rendered to WAV. pub mod preset; pub mod renderer; pub use preset::{PresetCategory, PresetEntry, PresetRegistry}; pub use renderer::render_spec; // Re-export core types that are part of the SoundSpec format pub use soundgen_core::SweepCurve; use soundgen_core::FrequencyAutomation; /// Top-level sound specification. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct SoundSpec { pub name: String, pub duration: f32, #[serde(default = "default_sample_rate")] pub sample_rate: u32, #[serde(default)] pub channels: Vec, } /// ADSR envelope spec. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EnvelopeSpec { #[serde(default)] pub attack: f32, #[serde(default)] pub decay: f32, #[serde(default = "default_sustain")] pub sustain: f32, #[serde(default)] pub release: f32, } /// Filter spec with optional cutoff automation. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct FilterSpec { #[serde(rename = "type")] pub kind: FilterKind, #[serde(default = "default_cutoff")] pub cutoff: f32, /// Optional cutoff sweep. If present, `cutoff` is the start value. #[serde(default)] pub cutoff_sweep: Option, #[serde(default = "default_q")] pub q: f32, } /// Cutoff frequency automation (start → end over the sound's duration). #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct CutoffAutomation { pub start: f32, pub end: f32, #[serde(default = "default_curve")] pub curve: soundgen_core::SweepCurve, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum FilterKind { Lowpass, Highpass, } /// Channel specification — discriminated by `type` field. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ChannelSpec { Pulse { /// Duty cycle percent (12, 25, 50, 75). #[serde(default = "default_duty")] duty: u8, #[serde(default)] frequency: FrequencyAutomation, #[serde(default)] envelope: Option, #[serde(default)] filter: Option, #[serde(default = "default_volume")] volume: f32, #[serde(default)] pan: f32, }, Triangle { #[serde(default)] frequency: FrequencyAutomation, #[serde(default)] envelope: Option, #[serde(default)] filter: Option, #[serde(default = "default_volume")] volume: f32, #[serde(default)] pan: f32, }, Noise { /// "white" or "periodic". #[serde(default = "default_noise_mode")] mode: String, /// Base frequency for the noise clock. #[serde(default = "default_noise_freq")] frequency: f32, #[serde(default)] envelope: Option, #[serde(default)] filter: Option, #[serde(default = "default_volume")] volume: f32, #[serde(default)] pan: f32, }, } // Defaults fn default_sample_rate() -> u32 { 44100 } fn default_sustain() -> f32 { 0.7 } fn default_cutoff() -> f32 { 5000.0 } fn default_q() -> f32 { 0.707 } fn default_curve() -> soundgen_core::SweepCurve { soundgen_core::SweepCurve::Linear } fn default_duty() -> u8 { 50 } fn default_volume() -> f32 { 0.7 } fn default_noise_mode() -> String { "white".to_string() } fn default_noise_freq() -> f32 { 8000.0 } impl Default for EnvelopeSpec { fn default() -> Self { Self { attack: 0.01, decay: 0.1, sustain: 0.0, release: 0.1, } } } impl Default for SoundSpec { fn default() -> Self { Self { name: String::new(), duration: 0.2, sample_rate: 44100, channels: vec![], } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_pulse_channel() { let json = r#"{ "type": "pulse", "duty": 50, "frequency": { "start": 200, "end": 800, "curve": "exponential" }, "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, "volume": 0.7 }"#; let ch: ChannelSpec = serde_json::from_str(json).unwrap(); match ch { ChannelSpec::Pulse { duty, volume, .. } => { assert_eq!(duty, 50); assert!((volume - 0.7).abs() < 0.01); } _ => panic!("expected Pulse"), } } #[test] fn test_parse_noise_channel() { let json = r#"{ "type": "noise", "mode": "white", "filter": { "type": "lowpass", "cutoff": 2000, "cutoff_sweep": { "start": 2000, "end": 200, "curve": "exponential" } }, "envelope": { "attack": 0.005, "decay": 0.7, "sustain": 0.0, "release": 0.095 }, "volume": 0.9 }"#; let ch: ChannelSpec = serde_json::from_str(json).unwrap(); match ch { ChannelSpec::Noise { mode, filter, .. } => { assert_eq!(mode, "white"); assert!(filter.is_some()); } _ => panic!("expected Noise"), } } #[test] fn test_parse_full_spec() { let json = r#"{ "name": "jump", "duration": 0.3, "sample_rate": 44100, "channels": [ { "type": "pulse", "duty": 50, "frequency": { "start": 200, "end": 800, "curve": "exponential" }, "envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 }, "volume": 0.7 } ] }"#; let spec: SoundSpec = serde_json::from_str(json).unwrap(); assert_eq!(spec.name, "jump"); assert!((spec.duration - 0.3).abs() < 0.001); assert_eq!(spec.channels.len(), 1); } #[test] fn test_parse_with_defaults() { let json = r#"{ "name": "test", "duration": 0.1, "channels": [ { "type": "triangle", "frequency": { "start": 220, "end": 220 } } ] }"#; let spec: SoundSpec = serde_json::from_str(json).unwrap(); assert_eq!(spec.sample_rate, 44100); // default match &spec.channels[0] { ChannelSpec::Triangle { volume, .. } => { assert!((volume - 0.7).abs() < 0.01); // default } _ => panic!("expected Triangle"), } } #[test] fn test_spec_serialization_roundtrip() { let spec = SoundSpec { name: "test".to_string(), duration: 0.5, sample_rate: 48000, channels: vec![ChannelSpec::Pulse { duty: 25, frequency: FrequencyAutomation::fixed(440.0), envelope: Some(EnvelopeSpec { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.2, }), filter: None, volume: 0.8, pan: -0.5, }], }; let json = serde_json::to_string_pretty(&spec).unwrap(); let spec2: SoundSpec = serde_json::from_str(&json).unwrap(); assert_eq!(spec2.name, spec.name); assert!((spec2.duration - spec.duration).abs() < 0.001); } }