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