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
295 lines
8.5 KiB
Rust
295 lines
8.5 KiB
Rust
//! Render a [`SoundSpec`] to interleaved stereo `Vec<f32>`.
|
|
use soundgen_core::{
|
|
ChannelRenderer, Envelope, Filter, FilterType, FrequencyAutomation, NoiseMode, Sweep, Vibrato,
|
|
VoiceKind,
|
|
};
|
|
use VoiceKind as VK;
|
|
|
|
use crate::{ChannelSpec, EnvelopeSpec, FilterSpec, SoundSpec, VibratoSpec};
|
|
|
|
/// 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,
|
|
vibrato,
|
|
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,
|
|
vibrato.as_ref(),
|
|
*volume,
|
|
*pan,
|
|
)
|
|
}
|
|
ChannelSpec::Triangle {
|
|
frequency,
|
|
envelope,
|
|
filter,
|
|
vibrato,
|
|
volume,
|
|
pan,
|
|
} => {
|
|
let voice = VK::Triangle(soundgen_core::voice::TriangleChannel::new(sr));
|
|
build_renderer(
|
|
voice,
|
|
sr,
|
|
duration,
|
|
frequency,
|
|
envelope,
|
|
filter,
|
|
vibrato.as_ref(),
|
|
*volume,
|
|
*pan,
|
|
)
|
|
}
|
|
ChannelSpec::Noise {
|
|
mode,
|
|
frequency,
|
|
envelope,
|
|
filter,
|
|
vibrato: _,
|
|
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);
|
|
let freq_auto = FrequencyAutomation::fixed(*frequency);
|
|
build_renderer(
|
|
voice, sr, duration, &freq_auto, envelope, filter, None, *volume, *pan,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_renderer(
|
|
voice: VoiceKind,
|
|
sr: f32,
|
|
duration: f32,
|
|
frequency: &FrequencyAutomation,
|
|
envelope: &Option<EnvelopeSpec>,
|
|
filter: &Option<FilterSpec>,
|
|
vibrato: Option<&VibratoSpec>,
|
|
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));
|
|
}
|
|
|
|
// Vibrato
|
|
if let Some(vib) = vibrato {
|
|
cr = cr.with_vibrato(Vibrato::new(sr, vib.rate, vib.depth));
|
|
}
|
|
|
|
// 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,
|
|
vibrato: 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,
|
|
vibrato: 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,
|
|
vibrato: 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,
|
|
}),
|
|
vibrato: None,
|
|
volume: 0.5,
|
|
pan: 0.0,
|
|
}],
|
|
};
|
|
let out = render_spec(&spec);
|
|
assert_eq!(out.len(), 8820);
|
|
}
|
|
}
|