Vibrato (LFO frequency modulation): - New VibratoSpec in SoundSpec: rate (Hz), depth (cents), delay (s) - ChannelRenderer applies vibrato to frequency each tick - Supported on Pulse and Triangle channels - GUI: vibrato controls (enable, rate, depth) in channel panel - Enables bird chirps, sirens, wobbles Feedback loop fixes: - generate_batch now accepts full SoundSpec array (not just names) so each sound is unique - render_sound returns reference examples from feedback DB - Fixed GUI bug: clicking stars no longer erases feedback text - Feedback text auto-saves on Enter / focus loss - Replaced emoji buttons (✓/🗑) with text (Save/Del) - Green saved indicator when feedback exists in DB 99 tests passing, 0 warnings
243 lines
7.3 KiB
Rust
243 lines
7.3 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,
|
|
vibrato: 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,
|
|
}),
|
|
vibrato: None,
|
|
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,
|
|
vibrato: 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,
|
|
vibrato: None,
|
|
volume: 0.5,
|
|
pan: -0.5,
|
|
},
|
|
ChannelSpec::Triangle {
|
|
frequency: FrequencyAutomation::fixed(220.0),
|
|
envelope: Some(EnvelopeSpec::default()),
|
|
filter: None,
|
|
vibrato: None,
|
|
volume: 0.4,
|
|
pan: 0.5,
|
|
},
|
|
ChannelSpec::Noise {
|
|
mode: "white".to_string(),
|
|
frequency: 5000.0,
|
|
envelope: Some(EnvelopeSpec::default()),
|
|
filter: None,
|
|
vibrato: 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);
|
|
}
|