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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user