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
169 lines
4.6 KiB
Rust
169 lines
4.6 KiB
Rust
//! Stereo mixer — sums multiple voices with per-channel volume and pan.
|
|
|
|
use crate::voice::VoiceKind;
|
|
use crate::Generator;
|
|
|
|
/// A single channel in the mixer: voice + amplitude + pan.
|
|
pub struct MixerChannel {
|
|
pub voice: VoiceKind,
|
|
pub volume: f32,
|
|
/// -1.0 = full left, 0.0 = center, 1.0 = full right.
|
|
pub pan: f32,
|
|
}
|
|
|
|
pub struct Mixer {
|
|
channels: Vec<MixerChannel>,
|
|
sample_rate: f32,
|
|
}
|
|
|
|
impl Mixer {
|
|
pub fn new(sample_rate: f32) -> Self {
|
|
Self {
|
|
channels: Vec::new(),
|
|
sample_rate,
|
|
}
|
|
}
|
|
|
|
pub fn push(&mut self, voice: VoiceKind, volume: f32, pan: f32) {
|
|
self.channels.push(MixerChannel {
|
|
voice,
|
|
volume: volume.clamp(0.0, 1.0),
|
|
pan: pan.clamp(-1.0, 1.0),
|
|
});
|
|
}
|
|
|
|
pub fn sample_rate(&self) -> f32 {
|
|
self.sample_rate
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.channels.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.channels.is_empty()
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
for ch in &mut self.channels {
|
|
ch.voice.reset();
|
|
}
|
|
}
|
|
|
|
/// Produce one stereo sample (left, right).
|
|
#[inline]
|
|
pub fn tick_stereo(&mut self) -> (f32, f32) {
|
|
let mut left = 0.0f32;
|
|
let mut right = 0.0f32;
|
|
for ch in &mut self.channels {
|
|
let sample = ch.voice.tick() * ch.volume;
|
|
// Equal-power panning
|
|
let pan_norm = (ch.pan + 1.0) * 0.5; // 0 = left, 1 = right
|
|
let left_gain = (1.0 - pan_norm).sqrt();
|
|
let right_gain = pan_norm.sqrt();
|
|
left += sample * left_gain;
|
|
right += sample * right_gain;
|
|
}
|
|
// Soft clip to prevent harsh clipping
|
|
let left = left.tanh();
|
|
let right = right.tanh();
|
|
(left, right)
|
|
}
|
|
|
|
/// Produce one mono sample (sum of all channels).
|
|
#[inline]
|
|
pub fn tick_mono(&mut self) -> f32 {
|
|
let (l, r) = self.tick_stereo();
|
|
(l + r) * 0.5
|
|
}
|
|
}
|
|
|
|
/// Render a mixer to a stereo Vec (interleaved L, R, L, R, ...).
|
|
pub fn render_stereo(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
|
|
let mut out = Vec::with_capacity(n_samples * 2);
|
|
for _ in 0..n_samples {
|
|
let (l, r) = mixer.tick_stereo();
|
|
out.push(l);
|
|
out.push(r);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Render a mixer to a mono Vec.
|
|
pub fn render_mono(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
|
|
let mut out = Vec::with_capacity(n_samples);
|
|
for _ in 0..n_samples {
|
|
out.push(mixer.tick_mono());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::generator::Voice;
|
|
use crate::voice::{DutyCycle, PulseChannel};
|
|
|
|
#[test]
|
|
fn test_mixer_empty() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let (l, r) = m.tick_stereo();
|
|
assert_eq!(l, 0.0);
|
|
assert_eq!(r, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixer_single_channel() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
|
|
pulse.note_on(440.0, 1.0);
|
|
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
|
|
let (l, r) = m.tick_stereo();
|
|
// Center pan, volume 0.5
|
|
assert!(l.abs() > 0.0 || r.abs() > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixer_pan_left() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
|
|
pulse.note_on(440.0, 1.0);
|
|
m.push(VoiceKind::Pulse(pulse), 1.0, -1.0); // full left
|
|
let (l, r) = m.tick_stereo();
|
|
assert!(l.abs() > 0.0);
|
|
// Right should be near zero (equal power pan at -1.0 → right_gain = 0)
|
|
assert!(r.abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixer_pan_right() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
|
|
pulse.note_on(440.0, 1.0);
|
|
m.push(VoiceKind::Pulse(pulse), 1.0, 1.0); // full right
|
|
let (l, r) = m.tick_stereo();
|
|
assert!(r.abs() > 0.0);
|
|
assert!(l.abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_mono_length() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
|
|
pulse.note_on(440.0, 1.0);
|
|
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
|
|
let out = render_mono(&mut m, 1000);
|
|
assert_eq!(out.len(), 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_stereo_length() {
|
|
let mut m = Mixer::new(44100.0);
|
|
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
|
|
pulse.note_on(440.0, 1.0);
|
|
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
|
|
let out = render_stereo(&mut m, 1000);
|
|
assert_eq!(out.len(), 2000); // interleaved
|
|
}
|
|
}
|