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