Files
Soundgen/crates/soundgen-runtime/src/dac.rs
T
Emil c7d6c40683 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
2026-06-21 22:07:05 +03:00

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);
}
}
}