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
196 lines
6.0 KiB
Rust
196 lines
6.0 KiB
Rust
//! 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);
|
|
}
|
|
}
|
|
}
|