Files
Soundgen/crates/soundgen-fmt/tests/integration.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

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