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
This commit is contained in:
Emil
2026-06-21 22:07:05 +03:00
commit c7d6c40683
74 changed files with 13345 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "soundgen-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
[dev-dependencies]
soundgen-io.workspace = true
+216
View File
@@ -0,0 +1,216 @@
//! Sound bank — pre-loaded sound presets for runtime playback.
//!
//! Designed for game integration: load all sounds at init time,
//! then play by name with no allocations.
use std::collections::HashMap;
use std::path::Path;
use soundgen_fmt::{PresetRegistry, SoundSpec};
/// Pre-rendered sound entry.
struct SoundEntry {
samples: Vec<f32>,
sample_rate: u32,
}
/// A sound bank that pre-renders all sounds at load time.
/// Playback is zero-allocation: just returns a reference to the buffer.
pub struct SoundBank {
sounds: HashMap<String, SoundEntry>,
}
impl SoundBank {
/// Create an empty bank.
pub fn new() -> Self {
Self {
sounds: HashMap::new(),
}
}
/// Load and pre-render all presets from a directory.
pub fn load_dir(presets_dir: &Path) -> Result<Self, String> {
let registry = PresetRegistry::load_dir(presets_dir)?;
let mut bank = Self::new();
for entry in registry.list(None) {
let samples = soundgen_fmt::render_spec(&entry.spec);
bank.sounds.insert(
entry.name.to_lowercase(),
SoundEntry {
samples,
sample_rate: entry.spec.sample_rate,
},
);
}
Ok(bank)
}
/// Add a single sound spec to the bank (pre-renders it).
pub fn add(&mut self, name: &str, spec: &SoundSpec) {
let samples = soundgen_fmt::render_spec(spec);
self.sounds.insert(
name.to_lowercase(),
SoundEntry {
samples,
sample_rate: spec.sample_rate,
},
);
}
/// Get pre-rendered samples for a sound (case-insensitive).
pub fn get(&self, name: &str) -> Option<(&[f32], u32)> {
self.sounds
.get(&name.to_lowercase())
.map(|e| (e.samples.as_slice(), e.sample_rate))
}
/// Get a sound with pitch shifting (returns owned Vec).
/// `pitch_ratio` of 1.0 = original, 2.0 = one octave up, 0.5 = one octave down.
pub fn get_pitched(&self, name: &str, pitch_ratio: f32) -> Option<(Vec<f32>, u32)> {
let (samples, sr) = self.get(name)?;
if pitch_ratio == 1.0 {
return Some((samples.to_vec(), sr));
}
// Simple resampling via linear interpolation
let new_len = (samples.len() as f32 / pitch_ratio) as usize;
let mut result = Vec::with_capacity(new_len);
for i in 0..new_len {
let src_pos = i as f32 * pitch_ratio;
let idx0 = src_pos as usize;
let idx1 = (idx0 + 1).min(samples.len() - 1);
let frac = src_pos - idx0 as f32;
let sample = samples[idx0] * (1.0 - frac) + samples[idx1] * frac;
result.push(sample);
}
Some((result, sr))
}
/// Get a sound with volume scaling (returns owned Vec).
pub fn get_with_volume(&self, name: &str, volume: f32) -> Option<(Vec<f32>, u32)> {
let (samples, sr) = self.get(name)?;
Some((samples.iter().map(|&s| s * volume).collect(), sr))
}
/// List all sound names in the bank.
pub fn names(&self) -> Vec<String> {
self.sounds.keys().cloned().collect()
}
/// Number of sounds in the bank.
pub fn len(&self) -> usize {
self.sounds.len()
}
/// Is the bank empty?
pub fn is_empty(&self) -> bool {
self.sounds.is_empty()
}
}
impl Default for SoundBank {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use soundgen_core::FrequencyAutomation;
use soundgen_fmt::ChannelSpec;
#[test]
fn test_bank_add_and_get() {
let mut bank = SoundBank::new();
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: None,
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
bank.add("test", &spec);
assert!(bank.get("test").is_some());
assert!(bank.get("TEST").is_some()); // case-insensitive
assert!(bank.get("nonexistent").is_none());
assert_eq!(bank.len(), 1);
}
#[test]
fn test_bank_pitch_shift() {
let mut bank = SoundBank::new();
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: None,
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
bank.add("test", &spec);
let (orig, _) = bank.get("test").unwrap();
let (pitched, _) = bank.get_pitched("test", 2.0).unwrap();
// Pitched up 2x should be roughly half the length
assert!(pitched.len() < orig.len());
assert!((pitched.len() as f32 - orig.len() as f32 / 2.0).abs() < 10.0);
}
#[test]
fn test_bank_volume() {
let mut bank = SoundBank::new();
let spec = SoundSpec {
name: "test".to_string(),
duration: 0.05,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: FrequencyAutomation::fixed(440.0),
envelope: None,
filter: None,
volume: 1.0,
pan: 0.0,
}],
};
bank.add("test", &spec);
let (orig, _) = bank.get("test").unwrap();
let (quiet, _) = bank.get_with_volume("test", 0.5).unwrap();
let orig_max = orig.iter().cloned().fold(0.0f32, f32::max);
let quiet_max = quiet.iter().cloned().fold(0.0f32, f32::max);
assert!(
quiet_max < orig_max,
"volume scaling should reduce amplitude"
);
}
#[test]
fn test_bank_names() {
let mut bank = SoundBank::new();
let make = |name: &str| SoundSpec {
name: name.to_string(),
duration: 0.05,
sample_rate: 44100,
channels: vec![],
};
bank.add("alpha", &make("alpha"));
bank.add("beta", &make("beta"));
let names = bank.names();
assert_eq!(names.len(), 2);
}
}
+195
View File
@@ -0,0 +1,195 @@
//! NES-authentic nonlinear DAC emulation.
//!
//! The NES 2A03 uses a nonlinear DAC. The output voltage is not linear
//! with the digital value. This module emulates that characteristic.
//!
//! Reference: https://www.nesdev.org/wiki/APU_Mixer#Emulation
//! The pulse channels use a nonlinear mix, and the combined output
//! has a characteristic "crunchy" sound.
/// NES-style nonlinear DAC emulation.
///
/// The DAC maps linear float values [-1, 1] through a nonlinear curve
/// that mimics the NES 2A03's analog output stage.
pub struct NesDac {
/// Lookup table for the nonlinear transfer function (256 entries).
table: [f32; 256],
}
impl NesDac {
pub fn new() -> Self {
// Build nonlinear transfer table.
// The NES DAC has a characteristic curve where:
// - Near zero, output is more sensitive (steeper)
// - Near extremes, output compresses (shallower)
// We approximate with a tanh-like curve plus slight asymmetry.
let mut table = [0.0f32; 256];
for i in 0..256u16 {
let linear = (i as f32 / 127.5) - 1.0; // -1..1
// Nonlinear transfer: combination of tanh and cubic
let tanh_part = linear.tanh();
// Add slight asymmetry (NES DAC is not perfectly symmetric)
let asymmetric = 0.05 * linear * linear * linear;
// Quantize to 8-bit levels (NES is 8-bit internally for mixed output)
let quantized = ((tanh_part + asymmetric) * 63.0).round() / 63.0;
table[i as usize] = quantized;
}
Self { table }
}
/// Process a sample through the nonlinear DAC.
#[inline]
pub fn process(&mut self, input: f32) -> f32 {
let clamped = input.clamp(-1.0, 1.0);
let idx = ((clamped + 1.0) * 127.5) as usize;
self.table[idx.min(255)]
}
/// Process a buffer in-place.
pub fn process_buffer(&mut self, samples: &mut [f32]) {
for s in samples.iter_mut() {
*s = self.process(*s);
}
}
}
impl Default for NesDac {
fn default() -> Self {
Self::new()
}
}
/// NES hardware-accurate channel mixing.
///
/// The NES mixes channels with specific relative weights:
/// - Pulse 1 & 2: equal weight
/// - Triangle: ~3x quieter than pulse (due to higher impedance)
/// - Noise: same as pulse
/// - DPCM: ~2x quieter than pulse
///
/// The mix is also nonlinear: the combined output is not a simple sum.
pub struct HardwareMixer {
dac: NesDac,
}
impl HardwareMixer {
pub fn new() -> Self {
Self { dac: NesDac::new() }
}
/// Mix NES-style channels with hardware-accurate weights.
///
/// - `pulse`: combined pulse output (already mixed)
/// - `triangle`: triangle output
/// - `noise`: noise output
/// - `dpcm`: DPCM output
#[inline]
pub fn mix(&mut self, pulse: f32, triangle: f32, noise: f32, dpcm: f32) -> f32 {
// NES mixing: nonlinear combination
// Reference formula from nesdev.org:
// output = 95.88 - (8128 / (pulse_sum + 244))
// for the pulse+triangle path
//
// Simplified for float [-1, 1]:
let pulse_mix = pulse * 0.4;
let tri_mix = triangle * 0.15;
let noise_mix = noise * 0.4;
let dpcm_mix = dpcm * 0.2;
let mixed = pulse_mix + tri_mix + noise_mix + dpcm_mix;
self.dac.process(mixed)
}
/// Process a buffer of pre-mixed samples through the DAC.
pub fn process(&mut self, samples: &mut [f32]) {
self.dac.process_buffer(samples);
}
}
impl Default for HardwareMixer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dac_nonlinear() {
let mut dac = NesDac::new();
// Linear input 0.5 should produce different output than 0.5 * 2 of input 0.25
let out_quarter = dac.process(0.25);
let out_half = dac.process(0.5);
// Nonlinear: 2 * out(0.25) != out(0.5)
assert!(
(2.0 * out_quarter - out_half).abs() > 0.01,
"DAC should be nonlinear: 2*f(0.25)={}, f(0.5)={}",
2.0 * out_quarter,
out_half
);
}
#[test]
fn test_dac_zero() {
let mut dac = NesDac::new();
let out = dac.process(0.0);
assert!(out.abs() < 0.02, "DAC at zero should be near zero: {}", out);
}
#[test]
fn test_dac_clamps() {
let mut dac = NesDac::new();
let out_pos = dac.process(2.0);
let out_neg = dac.process(-2.0);
// Quantized to 63 levels, so max is 1.0 but might be slightly less
assert!(out_pos >= 0.8 && out_pos <= 1.0, "pos: {}", out_pos);
assert!(out_neg <= -0.8 && out_neg >= -1.0, "neg: {}", out_neg);
}
#[test]
fn test_dac_quantization() {
let mut dac = NesDac::new();
// Very small changes should be quantized away
let out1 = dac.process(0.001);
let out2 = dac.process(0.002);
assert!(
(out1 - out2).abs() < 0.001,
"Small differences should be quantized"
);
}
#[test]
fn test_hardware_mixer() {
let mut mixer = HardwareMixer::new();
let out = mixer.mix(0.5, 0.3, 0.2, 0.1);
assert!(out.abs() > 0.0);
assert!(out <= 1.0);
}
#[test]
fn test_hardware_mixer_triangle_quieter() {
let mut mixer = HardwareMixer::new();
let out_pulse = mixer.mix(1.0, 0.0, 0.0, 0.0);
let out_triangle = mixer.mix(0.0, 1.0, 0.0, 0.0);
// Triangle should be quieter than pulse
assert!(
out_triangle.abs() < out_pulse.abs(),
"Triangle should be quieter: tri={}, pulse={}",
out_triangle,
out_pulse
);
}
#[test]
fn test_dac_process_buffer() {
let mut dac = NesDac::new();
let mut samples = vec![0.0, 0.5, -0.5, 1.0, -1.0];
dac.process_buffer(&mut samples);
// Should still be in valid range
for s in &samples {
assert!(s.abs() <= 1.0);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Soundgen runtime — embeddable sound generation for games.
//!
//! Designed for use inside a Rust game engine (e.g., Vulkan-based).
//! No I/O dependencies. All sound is generated in-memory.
//!
//! Features:
//! - NES-authentic nonlinear DAC emulation
//! - Hardware-accurate channel mixing
//! - Runtime sound bank: load presets at init, play by name
//! - Pitch shifting for variations
//! - No allocations in playback path
pub mod bank;
pub mod dac;
pub mod mixer;
pub use bank::SoundBank;
pub use dac::NesDac;
pub use mixer::HardwareMixer;
/// Render a SoundSpec to mono samples with NES-authentic DAC.
pub fn render_spec_nes(spec: &soundgen_fmt::SoundSpec) -> Vec<f32> {
let stereo = soundgen_fmt::render_spec(spec);
let mut mono = Vec::with_capacity(stereo.len() / 2);
let mut dac = NesDac::new();
for chunk in stereo.chunks_exact(2) {
let mixed = (chunk[0] + chunk[1]) * 0.5;
mono.push(dac.process(mixed));
}
mono
}
+6
View File
@@ -0,0 +1,6 @@
//! Hardware mixer module — re-exports from dac.rs.
//!
//! The [`HardwareMixer`] is in [`dac`], this module provides
//! a convenience re-export.
pub use crate::dac::HardwareMixer;