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
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "soundgen-seq"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
soundgen-core.workspace = true
serde.workspace = true
serde_json.workspace = true
+238
View File
@@ -0,0 +1,238 @@
//! Sequencer — pattern-based song playback.
//!
//! A [`Song`] contains multiple [`Pattern`]s played in order.
//! Each pattern is a grid of rows; each row has one note per track.
pub mod sequencer;
pub use sequencer::render_song;
use serde::{Deserialize, Serialize};
/// A musical note (frequency + velocity).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Note {
pub frequency: f32,
#[serde(default = "default_velocity")]
pub velocity: f32,
}
fn default_velocity() -> f32 {
1.0
}
/// A row in a pattern: one optional note per track.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Row {
/// `None` = rest, `Some(note)` = note on.
/// Length should match the number of tracks.
#[serde(default)]
pub notes: Vec<Option<Note>>,
}
/// A pattern: a sequence of rows.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Pattern {
#[serde(default)]
pub rows: Vec<Row>,
}
/// Track configuration: voice type + envelope + mix settings.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrackConfig {
#[serde(flatten)]
pub voice: TrackVoice,
#[serde(default)]
pub envelope: Option<EnvelopeConfig>,
#[serde(default = "default_volume")]
pub volume: f32,
#[serde(default)]
pub pan: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum TrackVoice {
Pulse {
#[serde(default = "default_duty")]
duty: u8,
},
Triangle,
Noise {
#[serde(default = "default_noise_mode")]
mode: String,
#[serde(default = "default_noise_freq")]
frequency: f32,
},
Wavetable,
Fm {
#[serde(default = "default_mod_ratio")]
mod_ratio: f32,
#[serde(default = "default_mod_index")]
mod_index: f32,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EnvelopeConfig {
#[serde(default)]
pub attack: f32,
#[serde(default)]
pub decay: f32,
#[serde(default = "default_sustain")]
pub sustain: f32,
#[serde(default)]
pub release: f32,
}
/// A complete song: patterns, track configs, and playback order.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Song {
pub bpm: f32,
#[serde(default = "default_rows_per_beat")]
pub rows_per_beat: u32,
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
pub tracks: Vec<TrackConfig>,
pub patterns: Vec<Pattern>,
/// Order of patterns to play (indices into `patterns`).
#[serde(default)]
pub pattern_order: Vec<usize>,
}
fn default_volume() -> f32 {
0.6
}
fn default_duty() -> u8 {
50
}
fn default_noise_mode() -> String {
"white".to_string()
}
fn default_noise_freq() -> f32 {
8000.0
}
fn default_mod_ratio() -> f32 {
2.0
}
fn default_mod_index() -> f32 {
1.0
}
fn default_sustain() -> f32 {
0.6
}
fn default_rows_per_beat() -> u32 {
4
}
fn default_sample_rate() -> u32 {
44100
}
impl Note {
/// Create a note from a MIDI note number.
pub fn from_midi(midi: u8, velocity: f32) -> Self {
let freq = 440.0 * 2.0f32.powf((midi as f32 - 69.0) / 12.0);
Self {
frequency: freq,
velocity,
}
}
/// Create a note from a note name (e.g., "A4", "C#5").
pub fn from_name(name: &str, velocity: f32) -> Option<Self> {
let note_names = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let note_part = &name[..name.len() - 1];
let octave: i32 = name[name.len() - 1..].parse().ok()?;
let semitone = note_names.iter().position(|&n| n == note_part)? as i32;
let midi = 12 * (octave + 1) + semitone;
Some(Self::from_midi(midi as u8, velocity))
}
}
impl Song {
/// Total duration in seconds.
pub fn duration(&self) -> f32 {
let total_rows: usize = self
.pattern_order
.iter()
.filter_map(|&i| self.patterns.get(i).map(|p| p.rows.len()))
.sum();
let seconds_per_row = 60.0 / self.bpm / self.rows_per_beat as f32;
total_rows as f32 * seconds_per_row
}
/// Total number of rows across all patterns in order.
pub fn total_rows(&self) -> usize {
self.pattern_order
.iter()
.filter_map(|&i| self.patterns.get(i).map(|p| p.rows.len()))
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_note_from_midi() {
let n = Note::from_midi(69, 1.0); // A4 = 440 Hz
assert!((n.frequency - 440.0).abs() < 0.1);
}
#[test]
fn test_note_from_name() {
let n = Note::from_name("A4", 1.0).unwrap();
assert!((n.frequency - 440.0).abs() < 0.1);
let n = Note::from_name("C4", 0.8).unwrap();
assert!((n.frequency - 261.63).abs() < 0.5);
let n = Note::from_name("C#4", 0.8).unwrap();
assert!((n.frequency - 277.18).abs() < 0.5);
}
#[test]
fn test_song_duration() {
let song = Song {
bpm: 120.0,
rows_per_beat: 4,
sample_rate: 44100,
tracks: vec![],
patterns: vec![Pattern {
rows: vec![Row { notes: vec![] }; 16],
}],
pattern_order: vec![0],
};
// 16 rows, 4 rows per beat, 120 bpm → 4 beats → 2 seconds
assert!((song.duration() - 2.0).abs() < 0.01);
}
#[test]
fn test_song_serde() {
let json = r#"{
"bpm": 140,
"rows_per_beat": 4,
"tracks": [
{ "type": "pulse", "duty": 50, "volume": 0.5 }
],
"patterns": [
{
"rows": [
{ "notes": [{ "frequency": 440, "velocity": 1.0 }] },
{ "notes": [null] }
]
}
],
"pattern_order": [0]
}"#;
let song: Song = serde_json::from_str(json).unwrap();
assert_eq!(song.bpm, 140.0);
assert_eq!(song.tracks.len(), 1);
assert_eq!(song.patterns[0].rows.len(), 2);
assert!(song.patterns[0].rows[0].notes[0].is_some());
assert!(song.patterns[0].rows[1].notes[0].is_none());
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Sequencer — renders a [`Song`] to interleaved stereo `Vec<f32>`.
use soundgen_core::generator::Voice;
use soundgen_core::{
voice::{
DutyCycle, FmChannel, NoiseChannel, NoiseMode, PulseChannel, TriangleChannel,
WavetableChannel,
},
ChannelRenderer, Envelope, VoiceKind,
};
use crate::{Song, TrackConfig, TrackVoice};
/// Render a [`Song`] to interleaved stereo samples (L, R, L, R, ...).
pub fn render_song(song: &Song) -> Vec<f32> {
let sr = song.sample_rate as f32;
let seconds_per_row = 60.0 / song.bpm / song.rows_per_beat as f32;
let samples_per_row = (seconds_per_row * sr) as usize;
// Build channel renderers from track configs
let mut channels: Vec<ChannelRenderer> = song
.tracks
.iter()
.map(|tc| build_track_renderer(tc, sr))
.collect();
// Trigger and render
let total_rows = song.total_rows();
let total_samples = total_rows * samples_per_row;
let mut out = Vec::with_capacity(total_samples * 2);
for &pattern_idx in &song.pattern_order {
let pattern = match song.patterns.get(pattern_idx) {
Some(p) => p,
None => continue,
};
for row in &pattern.rows {
// Trigger notes for this row
for (track_idx, note_opt) in row.notes.iter().enumerate() {
if track_idx >= channels.len() {
break;
}
if let Some(note) = note_opt {
channels[track_idx].trigger();
channels[track_idx].set_frequency(note.frequency);
}
}
// Render this row's worth of samples
for _ in 0..samples_per_row {
let mut left = 0.0f32;
let mut right = 0.0f32;
for ch in &mut channels {
let (l, r) = ch.tick();
left += l;
right += r;
}
out.push(left.tanh());
out.push(right.tanh());
}
}
}
out
}
fn build_track_renderer(tc: &TrackConfig, sr: f32) -> ChannelRenderer {
let voice = build_voice(&tc.voice, sr);
let mut cr = ChannelRenderer::new(voice, sr)
.with_volume(tc.volume)
.with_pan(tc.pan);
if let Some(env) = &tc.envelope {
cr = cr.with_envelope(Envelope::adsr(
sr,
env.attack,
env.decay,
env.sustain,
env.release,
));
}
cr
}
fn build_voice(tv: &TrackVoice, sr: f32) -> VoiceKind {
match tv {
TrackVoice::Pulse { duty } => {
VoiceKind::Pulse(PulseChannel::new(sr, DutyCycle::from_percent(*duty)))
}
TrackVoice::Triangle => VoiceKind::Triangle(TriangleChannel::new(sr)),
TrackVoice::Noise { mode, frequency } => {
let nm = match mode.as_str() {
"periodic" => NoiseMode::Periodic,
_ => NoiseMode::White,
};
let mut noise = NoiseChannel::new(sr, nm);
noise.note_on(*frequency, 1.0);
VoiceKind::Noise(noise)
}
TrackVoice::Wavetable => VoiceKind::Wavetable(WavetableChannel::new(sr)),
TrackVoice::Fm {
mod_ratio,
mod_index,
} => {
let mut fm = FmChannel::new(sr);
fm.set_mod_ratio(*mod_ratio);
fm.set_mod_index(*mod_index);
VoiceKind::Fm(fm)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Note, Pattern, Row, Song, TrackConfig, TrackVoice};
#[test]
fn test_render_simple_song() {
let song = Song {
bpm: 120.0,
rows_per_beat: 4,
sample_rate: 44100,
tracks: vec![TrackConfig {
voice: TrackVoice::Pulse { duty: 50 },
envelope: None,
volume: 0.5,
pan: 0.0,
}],
patterns: vec![Pattern {
rows: vec![
Row {
notes: vec![Some(Note::from_name("C4", 1.0).unwrap())],
},
Row { notes: vec![None] },
Row {
notes: vec![Some(Note::from_name("E4", 1.0).unwrap())],
},
Row { notes: vec![None] },
],
}],
pattern_order: vec![0],
};
let out = render_song(&song);
// 4 rows, 0.125s per row, 44100 Hz → 5512.5 samples per row → 22050 frames → 44100 interleaved
assert!(!out.is_empty());
// Should have some non-zero signal
let non_zero = out.iter().filter(|&&s| s.abs() > 0.001).count();
assert!(non_zero > 100, "expected non-zero output, got {}", non_zero);
}
#[test]
fn test_render_multi_track_song() {
let song = Song {
bpm: 100.0,
rows_per_beat: 4,
sample_rate: 22050,
tracks: vec![
TrackConfig {
voice: TrackVoice::Pulse { duty: 50 },
envelope: None,
volume: 0.4,
pan: -0.3,
},
TrackConfig {
voice: TrackVoice::Triangle,
envelope: None,
volume: 0.5,
pan: 0.3,
},
],
patterns: vec![Pattern {
rows: vec![
Row {
notes: vec![
Some(Note::from_name("A4", 1.0).unwrap()),
Some(Note::from_name("A2", 1.0).unwrap()),
],
},
Row {
notes: vec![None, None],
},
],
}],
pattern_order: vec![0],
};
let out = render_song(&song);
assert!(!out.is_empty());
}
}