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