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
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "soundgen-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "soundgen"
path = "src/main.rs"
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
soundgen-io.workspace = true
soundgen-seq.workspace = true
clap.workspace = true
serde_json.workspace = true
[dev-dependencies]
hound.workspace = true
+203
View File
@@ -0,0 +1,203 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "soundgen")]
#[command(version = "0.1.0")]
#[command(about = "8-bit sound synthesizer — generate SFX/UI/ambient sounds from JSON presets")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate a sound from a named preset.
Gen {
/// Preset name (e.g., "jump", "explosion", "click").
preset: String,
/// Output WAV path.
#[arg(short, long)]
out: PathBuf,
/// Override parameters (e.g., --param volume=0.9).
#[arg(short, long)]
param: Vec<String>,
/// Presets directory (defaults to bundled presets/).
#[arg(long, default_value = "presets")]
presets_dir: PathBuf,
},
/// Render a sound from a JSON spec file.
Render {
/// Input JSON spec file.
spec: PathBuf,
/// Output WAV path.
#[arg(short, long)]
out: PathBuf,
},
/// Render a song from a JSON song file (sequencer).
RenderSong {
/// Input JSON song file.
song: PathBuf,
/// Output WAV path.
#[arg(short, long)]
out: PathBuf,
},
/// List available presets.
ListPresets {
/// Filter by category: sfx, ui, or ambient.
#[arg(short, long)]
category: Option<String>,
/// Presets directory.
#[arg(long, default_value = "presets")]
presets_dir: PathBuf,
},
}
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<(), String> {
match cli.command {
Commands::Gen {
preset,
out,
param,
presets_dir,
} => cmd_gen(&preset, &out, &param, &presets_dir),
Commands::Render { spec, out } => cmd_render(&spec, &out),
Commands::RenderSong { song, out } => cmd_render_song(&song, &out),
Commands::ListPresets {
category,
presets_dir,
} => cmd_list_presets(category.as_deref(), &presets_dir),
}
}
fn cmd_gen(
preset_name: &str,
out: &std::path::Path,
params: &[String],
presets_dir: &std::path::Path,
) -> Result<(), String> {
let registry = soundgen_fmt::PresetRegistry::load_dir(presets_dir)
.map_err(|e| format!("loading presets from {}: {}", presets_dir.display(), e))?;
let entry = registry.get(preset_name).ok_or_else(|| {
let available = registry.names().join(", ");
format!(
"preset '{}' not found. Available: {}",
preset_name, available
)
})?;
let mut spec = entry.spec.clone();
// Apply parameter overrides
for p in params {
let (key, value) = p
.split_once('=')
.ok_or_else(|| format!("invalid --param format: '{}' (expected key=value)", p))?;
apply_param(&mut spec, key, value)?;
}
render_and_write(&spec, out)
}
fn cmd_render(spec_path: &std::path::Path, out: &std::path::Path) -> Result<(), String> {
let content = std::fs::read_to_string(spec_path)
.map_err(|e| format!("reading spec {}: {}", spec_path.display(), e))?;
let spec: soundgen_fmt::SoundSpec =
serde_json::from_str(&content).map_err(|e| format!("parsing spec: {}", e))?;
render_and_write(&spec, out)
}
fn cmd_render_song(song_path: &std::path::Path, out: &std::path::Path) -> Result<(), String> {
let content = std::fs::read_to_string(song_path)
.map_err(|e| format!("reading song {}: {}", song_path.display(), e))?;
let song: soundgen_seq::Song =
serde_json::from_str(&content).map_err(|e| format!("parsing song: {}", e))?;
let samples = soundgen_seq::render_song(&song);
soundgen_io::write_wav(out, &samples, song.sample_rate)?;
eprintln!(
"Wrote {} ({} samples, {:.1}s, {} Hz)",
out.display(),
samples.len() / 2,
song.duration(),
song.sample_rate
);
Ok(())
}
fn cmd_list_presets(category: Option<&str>, presets_dir: &std::path::Path) -> Result<(), String> {
let registry = soundgen_fmt::PresetRegistry::load_dir(presets_dir)
.map_err(|e| format!("loading presets: {}", e))?;
let cat = category.and_then(soundgen_fmt::PresetCategory::from_str);
let presets = registry.list(cat);
if presets.is_empty() {
println!("No presets found in {}", presets_dir.display());
return Ok(());
}
println!(
"{:<20} {:<10} {:<10} {}",
"NAME", "CATEGORY", "DURATION", "CHANNELS"
);
println!("{}", "-".repeat(60));
for p in &presets {
println!(
"{:<20} {:<10} {:<10.2} {}",
p.name,
p.category.as_str(),
p.spec.duration,
p.spec.channels.len()
);
}
Ok(())
}
fn render_and_write(spec: &soundgen_fmt::SoundSpec, out: &std::path::Path) -> Result<(), String> {
let samples = soundgen_fmt::render_spec(spec);
soundgen_io::write_wav(out, &samples, spec.sample_rate)?;
eprintln!(
"Wrote {} ({} samples, {:.2}s, {} Hz)",
out.display(),
samples.len() / 2,
spec.duration,
spec.sample_rate
);
Ok(())
}
fn apply_param(spec: &mut soundgen_fmt::SoundSpec, key: &str, value: &str) -> Result<(), String> {
let v: f32 = value
.parse()
.map_err(|e| format!("invalid param value '{}': {}", value, e))?;
match key {
"duration" => spec.duration = v,
"volume" => {
for ch in &mut spec.channels {
match ch {
soundgen_fmt::ChannelSpec::Pulse { volume, .. } => *volume = v,
soundgen_fmt::ChannelSpec::Triangle { volume, .. } => *volume = v,
soundgen_fmt::ChannelSpec::Noise { volume, .. } => *volume = v,
}
}
}
_ => {
return Err(format!(
"unknown param '{}' (supported: duration, volume)",
key
))
}
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "soundgen-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
+91
View File
@@ -0,0 +1,91 @@
//! Frequency automation — pitch envelope / sweep configuration.
//!
//! Serializable config that drives a [`Sweep`] at render time.
use crate::effect::{Sweep, SweepCurve};
/// Describes how a channel's frequency changes over its duration.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct FrequencyAutomation {
pub start: f32,
pub end: f32,
#[serde(default = "default_curve")]
pub curve: SweepCurve,
}
fn default_curve() -> SweepCurve {
SweepCurve::Linear
}
impl FrequencyAutomation {
/// Build a [`Sweep`] for this automation over `duration` seconds.
pub fn to_sweep(&self, sample_rate: f32, duration: f32) -> Sweep {
Sweep::new(sample_rate, self.start, self.end, self.curve, duration)
}
/// Static frequency (no sweep).
pub fn fixed(freq: f32) -> Self {
Self {
start: freq,
end: freq,
curve: SweepCurve::Linear,
}
}
pub fn is_static(&self) -> bool {
(self.start - self.end).abs() < 0.01
}
}
impl Default for FrequencyAutomation {
fn default() -> Self {
Self::fixed(440.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_automation_is_static() {
let a = FrequencyAutomation::fixed(440.0);
assert!(a.is_static());
}
#[test]
fn test_sweep_automation_not_static() {
let a = FrequencyAutomation {
start: 200.0,
end: 800.0,
curve: SweepCurve::Exponential,
};
assert!(!a.is_static());
}
#[test]
fn test_to_sweep_produces_valid_sweep() {
let a = FrequencyAutomation {
start: 100.0,
end: 200.0,
curve: SweepCurve::Linear,
};
let mut s = a.to_sweep(100.0, 1.0);
s.trigger();
s.tick();
assert!((s.current() - 100.0).abs() < 2.0);
}
#[test]
fn test_serde_roundtrip() {
let a = FrequencyAutomation {
start: 100.0,
end: 400.0,
curve: SweepCurve::Exponential,
};
let json = serde_json::to_string(&a).unwrap();
let a2: FrequencyAutomation = serde_json::from_str(&json).unwrap();
assert!((a2.start - a.start).abs() < 0.01);
assert_eq!(a2.curve, a.curve);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Channel renderer — wraps a voice with envelope, frequency sweep, and filter.
//!
//! Produces stereo samples (left, right) with volume and pan applied.
use crate::effect::{Envelope, Filter, Sweep};
use crate::generator::{Generator, Voice};
use crate::voice::VoiceKind;
pub struct ChannelRenderer {
voice: VoiceKind,
envelope: Envelope,
freq_sweep: Option<Sweep>,
/// Frequency used when there's no sweep (static frequency).
initial_frequency: f32,
filter: Option<Filter>,
filter_sweep: Option<Sweep>,
volume: f32,
pan: f32,
}
impl ChannelRenderer {
pub fn new(voice: VoiceKind, sample_rate: f32) -> Self {
Self {
voice,
envelope: Envelope::new(sample_rate),
freq_sweep: None,
initial_frequency: 440.0,
filter: None,
filter_sweep: None,
volume: 1.0,
pan: 0.0,
}
}
pub fn with_envelope(mut self, envelope: Envelope) -> Self {
self.envelope = envelope;
self
}
pub fn with_freq_sweep(mut self, sweep: Sweep) -> Self {
self.freq_sweep = Some(sweep);
self
}
/// Set the static frequency (used when no sweep is present).
pub fn with_initial_frequency(mut self, freq: f32) -> Self {
self.initial_frequency = freq;
self
}
pub fn with_filter(mut self, filter: Filter) -> Self {
self.filter = Some(filter);
self
}
pub fn with_filter_sweep(mut self, sweep: Sweep) -> Self {
self.filter_sweep = Some(sweep);
self
}
pub fn with_volume(mut self, volume: f32) -> Self {
self.volume = volume.clamp(0.0, 1.0);
self
}
pub fn with_pan(mut self, pan: f32) -> Self {
self.pan = pan.clamp(-1.0, 1.0);
self
}
/// Trigger note on + envelope + sweeps.
pub fn trigger(&mut self) {
let freq = self
.freq_sweep
.as_ref()
.map(|s| s.current())
.unwrap_or(self.initial_frequency);
self.voice.note_on(freq, 1.0);
self.envelope.trigger();
if let Some(s) = &mut self.freq_sweep {
s.trigger();
}
if let Some(s) = &mut self.filter_sweep {
s.trigger();
}
}
/// Release the envelope (note off).
pub fn release(&mut self) {
self.envelope.release();
}
/// Set the voice frequency directly (overrides any sweep).
pub fn set_frequency(&mut self, freq: f32) {
if self.freq_sweep.is_none() {
self.voice.set_frequency(freq);
}
}
/// Produce one stereo sample (left, right).
#[inline]
pub fn tick(&mut self) -> (f32, f32) {
// Update frequency from sweep
if let Some(sweep) = &mut self.freq_sweep {
let freq = sweep.tick();
self.voice.set_frequency(freq);
}
// Tick voice
let mut sample = self.voice.tick();
// Update filter cutoff from sweep
if let (Some(filter), Some(fsweep)) = (&mut self.filter, &mut self.filter_sweep) {
let cutoff = fsweep.tick();
filter.set_cutoff(cutoff);
}
// Apply filter
if let Some(filter) = &mut self.filter {
sample = filter.process(sample);
}
// Apply envelope
let env = self.envelope.tick();
sample *= env * self.volume;
// Equal-power pan
let pan_norm = (self.pan + 1.0) * 0.5;
let left_gain = (1.0 - pan_norm).sqrt();
let right_gain = pan_norm.sqrt();
(sample * left_gain, sample * right_gain)
}
pub fn is_finished(&self) -> bool {
self.envelope.is_finished()
}
pub fn reset(&mut self) {
self.voice.reset();
self.envelope.reset();
if let Some(s) = &mut self.freq_sweep {
s.reset();
}
if let Some(s) = &mut self.filter_sweep {
s.reset();
}
if let Some(f) = &mut self.filter {
f.reset();
}
}
}
/// Render multiple channel renderers to interleaved stereo Vec (L, R, L, R, ...).
pub fn render_channels(channels: &mut [ChannelRenderer], n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples * 2);
for _ in 0..n_samples {
let mut left = 0.0f32;
let mut right = 0.0f32;
for ch in channels.iter_mut() {
let (l, r) = ch.tick();
left += l;
right += r;
}
// Soft clip
out.push(left.tanh());
out.push(right.tanh());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::voice::{DutyCycle, PulseChannel};
#[test]
fn test_channel_renderer_basic() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5);
ch.trigger();
// Envelope starts at 0 in attack phase; tick a few times to get non-zero
let mut found_signal = false;
for _ in 0..100 {
let (l, r) = ch.tick();
if l.abs() > 0.0 || r.abs() > 0.0 {
found_signal = true;
break;
}
}
assert!(found_signal);
}
#[test]
fn test_channel_renderer_envelope_decays() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0)
.with_envelope(Envelope::adsr(44100.0, 0.0, 0.0, 0.0, 0.01));
ch.trigger();
// With sustain=0 and 0 decay, envelope drops to 0 immediately
let (_l, _r) = ch.tick();
// After attack (0s) → decay (0s) → sustain (0), should be silent
let (l2, r2) = ch.tick();
assert!(l2.abs() < 0.01 && r2.abs() < 0.01);
}
#[test]
fn test_render_channels_length() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5);
ch.trigger();
let out = render_channels(&mut [ch], 500);
assert_eq!(out.len(), 1000);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! ADSR envelope generator.
//!
//! Phases: Attack → Decay → Sustain → Release → Idle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnvelopePhase {
Idle,
Attack,
Decay,
Sustain,
Release,
}
#[derive(Clone, Debug)]
pub struct Envelope {
sample_rate: f32,
attack: f32,
decay: f32,
sustain: f32,
release: f32,
phase: EnvelopePhase,
level: f32,
release_start: f32,
sample_counter: f32,
}
impl Envelope {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
attack: 0.01,
decay: 0.1,
sustain: 0.7,
release: 0.1,
phase: EnvelopePhase::Idle,
level: 0.0,
release_start: 0.0,
sample_counter: 0.0,
}
}
pub fn adsr(sample_rate: f32, attack: f32, decay: f32, sustain: f32, release: f32) -> Self {
Self {
sample_rate,
attack,
decay,
sustain,
release,
phase: EnvelopePhase::Idle,
level: 0.0,
release_start: 0.0,
sample_counter: 0.0,
}
}
pub fn trigger(&mut self) {
self.phase = EnvelopePhase::Attack;
self.level = 0.0;
self.sample_counter = 0.0;
}
pub fn release(&mut self) {
if self.phase != EnvelopePhase::Idle {
self.phase = EnvelopePhase::Release;
self.release_start = self.level;
self.sample_counter = 0.0;
}
}
#[inline]
fn samples_for(&self, seconds: f32) -> f32 {
seconds * self.sample_rate
}
/// Produce next envelope amplitude [0, 1].
#[inline]
pub fn tick(&mut self) -> f32 {
match self.phase {
EnvelopePhase::Idle => {
self.level = 0.0;
}
EnvelopePhase::Attack => {
let attack_samples = self.samples_for(self.attack);
if attack_samples > 0.0 {
self.level = self.sample_counter / attack_samples;
} else {
self.level = 1.0;
}
self.sample_counter += 1.0;
if self.level >= 1.0 || self.sample_counter >= attack_samples {
self.level = 1.0;
self.phase = EnvelopePhase::Decay;
self.sample_counter = 0.0;
}
}
EnvelopePhase::Decay => {
let decay_samples = self.samples_for(self.decay);
if decay_samples > 0.0 {
self.level = 1.0 - (1.0 - self.sustain) * (self.sample_counter / decay_samples);
} else {
self.level = self.sustain;
}
self.sample_counter += 1.0;
if self.sample_counter >= decay_samples || self.level <= self.sustain {
self.level = self.sustain;
self.phase = EnvelopePhase::Sustain;
self.sample_counter = 0.0;
}
}
EnvelopePhase::Sustain => {
self.level = self.sustain;
}
EnvelopePhase::Release => {
let release_samples = self.samples_for(self.release);
if release_samples > 0.0 {
self.level = self.release_start * (1.0 - self.sample_counter / release_samples);
} else {
self.level = 0.0;
}
self.sample_counter += 1.0;
if self.level <= 0.0 || self.sample_counter >= release_samples {
self.level = 0.0;
self.phase = EnvelopePhase::Idle;
self.sample_counter = 0.0;
}
}
}
self.level.clamp(0.0, 1.0)
}
pub fn is_finished(&self) -> bool {
self.phase == EnvelopePhase::Idle
}
pub fn phase(&self) -> EnvelopePhase {
self.phase
}
pub fn reset(&mut self) {
self.phase = EnvelopePhase::Idle;
self.level = 0.0;
self.release_start = 0.0;
self.sample_counter = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_envelope_idle() {
let mut env = Envelope::new(44100.0);
assert_eq!(env.tick(), 0.0);
assert!(env.is_finished());
}
#[test]
fn test_envelope_attack() {
let mut env = Envelope::adsr(44100.0, 0.1, 0.1, 0.5, 0.1);
env.trigger();
assert_eq!(env.phase(), EnvelopePhase::Attack);
// First sample should be near 0
let s0 = env.tick();
assert!(s0 < 0.01);
// After ~half attack, should be ~0.5
for _ in 0..2205 {
env.tick();
}
assert!(env.level > 0.4 && env.level < 0.6);
// After full attack, should be in decay
for _ in 0..2205 {
env.tick();
}
assert_eq!(env.phase(), EnvelopePhase::Decay);
}
#[test]
fn test_envelope_sustain() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 0.7, 0.1);
env.trigger();
env.tick(); // attack (0 samples → immediate)
env.tick(); // decay (0 samples → immediate)
assert_eq!(env.phase(), EnvelopePhase::Sustain);
let s = env.tick();
assert!((s - 0.7).abs() < 0.01);
}
#[test]
fn test_envelope_release() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 1.0, 0.1);
env.trigger();
env.tick();
env.tick();
assert!((env.level - 1.0).abs() < 0.01);
env.release();
let s0 = env.tick();
assert!(s0 <= 1.0);
// After full release, should be idle
for _ in 0..4500 {
env.tick();
}
assert!(env.is_finished());
}
#[test]
fn test_zero_attack_immediate() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.1, 0.5, 0.1);
env.trigger();
env.tick();
// With 0 attack, should immediately be at 1.0 or in decay
assert!(env.level >= 0.99 || env.phase() == EnvelopePhase::Decay);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Biquad filter — lowpass / highpass.
//!
//! Standard second-order IIR filter with coefficient calculation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilterType {
Lowpass,
Highpass,
}
/// Stateful biquad filter. Process one sample at a time.
pub struct Filter {
filter_type: FilterType,
sample_rate: f32,
cutoff: f32,
q: f32,
// Coefficients
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
// State (Direct Form I)
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl Filter {
pub fn new(sample_rate: f32, filter_type: FilterType, cutoff: f32, q: f32) -> Self {
let mut f = Self {
filter_type,
sample_rate,
cutoff,
q,
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
};
f.recalc();
f
}
pub fn set_cutoff(&mut self, cutoff: f32) {
self.cutoff = cutoff;
self.recalc();
}
pub fn set_q(&mut self, q: f32) {
self.q = q;
self.recalc();
}
fn recalc(&mut self) {
let w0 = 2.0 * std::f32::consts::PI * self.cutoff / self.sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / (2.0 * self.q);
let (b0, b1, b2, a0, a1, a2);
match self.filter_type {
FilterType::Lowpass => {
b0 = (1.0 - cos_w0) / 2.0;
b1 = 1.0 - cos_w0;
b2 = (1.0 - cos_w0) / 2.0;
a0 = 1.0 + alpha;
a1 = -2.0 * cos_w0;
a2 = 1.0 - alpha;
}
FilterType::Highpass => {
b0 = (1.0 + cos_w0) / 2.0;
b1 = -(1.0 + cos_w0);
b2 = (1.0 + cos_w0) / 2.0;
a0 = 1.0 + alpha;
a1 = -2.0 * cos_w0;
a2 = 1.0 - alpha;
}
}
// Normalize by a0
self.b0 = b0 / a0;
self.b1 = b1 / a0;
self.b2 = b2 / a0;
self.a1 = a1 / a0;
self.a2 = a2 / a0;
}
/// Process one sample through the filter.
#[inline]
pub fn process(&mut self, input: f32) -> f32 {
let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = input;
self.y2 = self.y1;
self.y1 = output;
output
}
pub fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lowpass_attenuates_high_freq() {
let mut lp = Filter::new(44100.0, FilterType::Lowpass, 500.0, 0.707);
// Feed high-frequency signal (alternating +1/-1 per sample = Nyquist freq)
let mut amp_high = 0.0;
for i in 0..1000 {
let input = if i % 2 == 0 { 1.0 } else { -1.0 };
amp_high += lp.process(input).abs();
}
// Feed low-frequency signal (constant = DC, passes through lowpass)
let mut amp_low = 0.0;
for _ in 0..1000 {
amp_low += lp.process(1.0).abs();
}
// Lowpass should pass low freq better than high freq
assert!(
amp_low > amp_high,
"lowpass should attenuate high freq: low={} high={}",
amp_low,
amp_high
);
}
#[test]
fn test_highpass_attenuates_low_freq() {
let mut hp = Filter::new(44100.0, FilterType::Highpass, 2000.0, 0.707);
// DC (low freq) should be attenuated
let mut amp_dc = 0.0;
for _ in 0..1000 {
let s = hp.process(1.0);
amp_dc += s.abs();
}
// High freq (alternating) should pass
hp.reset();
let mut amp_hf = 0.0;
for _ in 0..500 {
let s = hp.process(1.0);
amp_hf += s.abs();
}
for _ in 0..500 {
let s = hp.process(-1.0);
amp_hf += s.abs();
}
assert!(amp_hf > amp_dc, "highpass should attenuate DC");
}
#[test]
fn test_filter_reset() {
let mut f = Filter::new(44100.0, FilterType::Lowpass, 1000.0, 0.707);
for _ in 0..100 {
f.process(0.5);
}
f.reset();
// After reset, state should be zero
assert_eq!(f.x1, 0.0);
assert_eq!(f.y1, 0.0);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Audio effects: envelope, sweep, filter, vibrato.
pub mod envelope;
pub mod filter;
pub mod sweep;
pub mod vibrato;
pub use envelope::{Envelope, EnvelopePhase};
pub use filter::{Filter, FilterType};
pub use sweep::{Sweep, SweepCurve};
pub use vibrato::Vibrato;
+144
View File
@@ -0,0 +1,144 @@
//! Frequency sweep — pitch automation over time.
//!
//! Linear: freq = start + (end - start) * t
//! Exponential: freq = start * (end / start) ^ t
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SweepCurve {
Linear,
Exponential,
}
pub struct Sweep {
sample_rate: f32,
start: f32,
end: f32,
curve: SweepCurve,
duration: f32,
sample_counter: f32,
current: f32,
active: bool,
}
impl Sweep {
pub fn new(sample_rate: f32, start: f32, end: f32, curve: SweepCurve, duration: f32) -> Self {
Self {
sample_rate,
start,
end,
curve,
duration,
sample_counter: 0.0,
current: start,
active: false,
}
}
pub fn trigger(&mut self) {
self.sample_counter = 0.0;
self.current = self.start;
self.active = true;
}
pub fn stop(&mut self) {
self.active = false;
}
#[inline]
pub fn tick(&mut self) -> f32 {
if !self.active {
return self.current;
}
let total_samples = self.duration * self.sample_rate;
if total_samples <= 0.0 {
self.current = self.end;
self.active = false;
return self.current;
}
let t = self.sample_counter / total_samples;
if t >= 1.0 {
self.current = self.end;
self.active = false;
return self.current;
}
self.current = match self.curve {
SweepCurve::Linear => self.start + (self.end - self.start) * t,
SweepCurve::Exponential => {
if self.start <= 0.0 {
self.start + (self.end - self.start) * t
} else {
self.start * (self.end / self.start).powf(t)
}
}
};
self.sample_counter += 1.0;
self.current
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn current(&self) -> f32 {
self.current
}
pub fn reset(&mut self) {
self.sample_counter = 0.0;
self.current = self.start;
self.active = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_sweep() {
let mut sweep = Sweep::new(100.0, 100.0, 200.0, SweepCurve::Linear, 1.0);
sweep.trigger();
// t=0 → start
let f0 = sweep.tick();
assert!((f0 - 100.0).abs() < 2.0);
// t=0.5 → midpoint
for _ in 0..49 {
sweep.tick();
}
assert!((sweep.current() - 150.0).abs() < 3.0);
// t=1.0 → end (need 101 ticks: tick 101 hits t=1.0)
for _ in 0..51 {
sweep.tick();
}
assert!((sweep.current() - 200.0).abs() < 1.0);
assert!(!sweep.is_active());
}
#[test]
fn test_exponential_sweep() {
let mut sweep = Sweep::new(100.0, 100.0, 400.0, SweepCurve::Exponential, 1.0);
sweep.trigger();
// t=0 → start
sweep.tick();
assert!((sweep.current() - 100.0).abs() < 3.0);
// t=0.5 → 100 * 4^0.5 = 200
for _ in 0..49 {
sweep.tick();
}
assert!((sweep.current() - 200.0).abs() < 10.0);
// t=1.0 → 400
for _ in 0..51 {
sweep.tick();
}
assert!((sweep.current() - 400.0).abs() < 2.0);
}
#[test]
fn test_sweep_not_active_returns_current() {
let mut sweep = Sweep::new(100.0, 200.0, 300.0, SweepCurve::Linear, 1.0);
// Not triggered
let f = sweep.tick();
assert_eq!(f, 200.0); // current = start = 200
}
}
@@ -0,0 +1,89 @@
//! Vibrato — LFO for frequency modulation.
//!
//! Modulates the pitch by a sine wave at a given rate and depth.
pub struct Vibrato {
sample_rate: f32,
freq: f32,
depth: f32,
phase: f32,
active: bool,
}
impl Vibrato {
/// Create a vibrato with given LFO frequency (Hz) and depth (in cents).
pub fn new(sample_rate: f32, freq: f32, depth_cents: f32) -> Self {
Self {
sample_rate,
freq,
depth: depth_cents,
phase: 0.0,
active: false,
}
}
pub fn trigger(&mut self) {
self.phase = 0.0;
self.active = true;
}
pub fn stop(&mut self) {
self.active = false;
}
/// Returns the frequency multiplier for the current tick.
/// Multiply this by the base frequency to get the vibrato-modulated frequency.
#[inline]
pub fn tick(&mut self) -> f32 {
if !self.active {
return 1.0;
}
let lfo = (2.0 * std::f32::consts::PI * self.phase).sin();
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
// Convert cents to frequency ratio: 2^(cents/1200)
let cents = lfo * self.depth;
2.0f32.powf(cents / 1200.0)
}
pub fn reset(&mut self) {
self.phase = 0.0;
self.active = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vibrato_inactive_returns_unity() {
let mut v = Vibrato::new(44100.0, 5.0, 50.0);
assert!((v.tick() - 1.0).abs() < 0.001);
}
#[test]
fn test_vibrato_modulates() {
let mut v = Vibrato::new(100.0, 10.0, 100.0); // 10 Hz, 100 cents
v.trigger();
let mut ratios = Vec::new();
for _ in 0..100 {
ratios.push(v.tick());
}
// Should vary from unity
let min = ratios.iter().cloned().fold(1.0f32, f32::min);
let max = ratios.iter().cloned().fold(1.0f32, f32::max);
assert!(min < 0.99, "vibrato should go below unity: min={}", min);
assert!(max > 1.01, "vibrato should go above unity: max={}", max);
}
#[test]
fn test_vibrato_depth_zero_is_unity() {
let mut v = Vibrato::new(44100.0, 5.0, 0.0);
v.trigger();
for _ in 0..100 {
assert!((v.tick() - 1.0).abs() < 0.001);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Core traits for sound generation.
/// A sample-by-sample generator. Produces one mono sample per `tick()`.
///
/// Output range: -1.0 ..= 1.0.
/// Implementations must not allocate in `tick()`.
pub trait Generator {
fn tick(&mut self) -> f32;
fn reset(&mut self);
}
/// A playable voice with note on/off semantics.
pub trait Voice: Generator {
fn note_on(&mut self, freq: f32, velocity: f32);
fn note_off(&mut self);
fn set_frequency(&mut self, freq: f32);
fn is_active(&self) -> bool;
}
+20
View File
@@ -0,0 +1,20 @@
//! Core synthesis engine: generators, effects, mixer.
//!
//! No I/O. Renders to `Vec<f32>`.
pub mod automation;
pub mod channel;
pub mod effect;
pub mod generator;
pub mod mixer;
pub mod voice;
pub use automation::FrequencyAutomation;
pub use channel::{render_channels, ChannelRenderer};
pub use effect::{Envelope, EnvelopePhase, Filter, FilterType, Sweep, SweepCurve, Vibrato};
pub use generator::{Generator, Voice};
pub use mixer::Mixer;
pub use voice::{
DpcmChannel, DutyCycle, FmChannel, NoiseMode, PulseChannel, TriangleChannel, VoiceKind,
WavetableChannel,
};
+168
View File
@@ -0,0 +1,168 @@
//! Stereo mixer — sums multiple voices with per-channel volume and pan.
use crate::voice::VoiceKind;
use crate::Generator;
/// A single channel in the mixer: voice + amplitude + pan.
pub struct MixerChannel {
pub voice: VoiceKind,
pub volume: f32,
/// -1.0 = full left, 0.0 = center, 1.0 = full right.
pub pan: f32,
}
pub struct Mixer {
channels: Vec<MixerChannel>,
sample_rate: f32,
}
impl Mixer {
pub fn new(sample_rate: f32) -> Self {
Self {
channels: Vec::new(),
sample_rate,
}
}
pub fn push(&mut self, voice: VoiceKind, volume: f32, pan: f32) {
self.channels.push(MixerChannel {
voice,
volume: volume.clamp(0.0, 1.0),
pan: pan.clamp(-1.0, 1.0),
});
}
pub fn sample_rate(&self) -> f32 {
self.sample_rate
}
pub fn len(&self) -> usize {
self.channels.len()
}
pub fn is_empty(&self) -> bool {
self.channels.is_empty()
}
pub fn reset(&mut self) {
for ch in &mut self.channels {
ch.voice.reset();
}
}
/// Produce one stereo sample (left, right).
#[inline]
pub fn tick_stereo(&mut self) -> (f32, f32) {
let mut left = 0.0f32;
let mut right = 0.0f32;
for ch in &mut self.channels {
let sample = ch.voice.tick() * ch.volume;
// Equal-power panning
let pan_norm = (ch.pan + 1.0) * 0.5; // 0 = left, 1 = right
let left_gain = (1.0 - pan_norm).sqrt();
let right_gain = pan_norm.sqrt();
left += sample * left_gain;
right += sample * right_gain;
}
// Soft clip to prevent harsh clipping
let left = left.tanh();
let right = right.tanh();
(left, right)
}
/// Produce one mono sample (sum of all channels).
#[inline]
pub fn tick_mono(&mut self) -> f32 {
let (l, r) = self.tick_stereo();
(l + r) * 0.5
}
}
/// Render a mixer to a stereo Vec (interleaved L, R, L, R, ...).
pub fn render_stereo(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples * 2);
for _ in 0..n_samples {
let (l, r) = mixer.tick_stereo();
out.push(l);
out.push(r);
}
out
}
/// Render a mixer to a mono Vec.
pub fn render_mono(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
out.push(mixer.tick_mono());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generator::Voice;
use crate::voice::{DutyCycle, PulseChannel};
#[test]
fn test_mixer_empty() {
let mut m = Mixer::new(44100.0);
let (l, r) = m.tick_stereo();
assert_eq!(l, 0.0);
assert_eq!(r, 0.0);
}
#[test]
fn test_mixer_single_channel() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let (l, r) = m.tick_stereo();
// Center pan, volume 0.5
assert!(l.abs() > 0.0 || r.abs() > 0.0);
}
#[test]
fn test_mixer_pan_left() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 1.0, -1.0); // full left
let (l, r) = m.tick_stereo();
assert!(l.abs() > 0.0);
// Right should be near zero (equal power pan at -1.0 → right_gain = 0)
assert!(r.abs() < 0.01);
}
#[test]
fn test_mixer_pan_right() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 1.0, 1.0); // full right
let (l, r) = m.tick_stereo();
assert!(r.abs() > 0.0);
assert!(l.abs() < 0.01);
}
#[test]
fn test_render_mono_length() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let out = render_mono(&mut m, 1000);
assert_eq!(out.len(), 1000);
}
#[test]
fn test_render_stereo_length() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let out = render_stereo(&mut m, 1000);
assert_eq!(out.len(), 2000); // interleaved
}
}
+191
View File
@@ -0,0 +1,191 @@
//! DPCM channel — 8-bit sample playback (NES DPCM-style).
//!
//! Plays back pre-decoded PCM samples at a variable clock rate.
//! Supports looping and pitch control via the clock frequency.
use crate::{Generator, Voice};
pub struct DpcmChannel {
sample_rate: f32,
freq: f32,
samples: Vec<f32>,
position: f32,
velocity: f32,
active: bool,
looping: bool,
}
impl DpcmChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 8000.0,
samples: vec![],
position: 0.0,
velocity: 0.0,
active: false,
looping: false,
}
}
/// Load 8-bit unsigned PCM samples (0-255).
pub fn load_pcm_u8(&mut self, data: &[u8]) {
self.samples = data.iter().map(|&b| (b as f32 / 128.0) - 1.0).collect();
}
/// Load float samples (-1..1).
pub fn load_samples(&mut self, data: &[f32]) {
self.samples = data.to_vec();
}
pub fn set_looping(&mut self, looping: bool) {
self.looping = looping;
}
/// Generate a simple kick drum sample.
pub fn kick() -> Vec<u8> {
let mut data = Vec::new();
let n = 2000;
for i in 0..n {
let t = i as f32 / n as f32;
let freq = 150.0 * (1.0 - t * 0.8);
let env = (1.0 - t).powi(2);
let s = (t * freq * 2.0 * std::f32::consts::PI).sin() * env;
data.push(((s * 0.8 + 1.0) * 128.0) as u8);
}
data
}
/// Generate a simple snare sample.
pub fn snare() -> Vec<u8> {
let mut data = Vec::new();
let n = 1500;
let mut lfsr: u16 = 0x1234;
for i in 0..n {
let t = i as f32 / n as f32;
let env = (1.0 - t).powi(1);
let bit0 = lfsr & 1;
let bit8 = (lfsr >> 8) & 1;
lfsr >>= 1;
lfsr |= (bit0 ^ bit8) << 14;
let noise = if (lfsr & 1) == 0 { 1.0 } else { -1.0 };
let tone = (t * 200.0 * 2.0 * std::f32::consts::PI).sin() * 0.3;
let s = (noise * 0.7 + tone) * env;
data.push(((s * 0.7 + 1.0) * 128.0) as u8);
}
data
}
}
impl Generator for DpcmChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active || self.samples.is_empty() {
return 0.0;
}
let idx = self.position as usize;
if idx >= self.samples.len() {
if self.looping {
self.position = 0.0;
} else {
self.active = false;
return 0.0;
}
}
let sample = self.samples[self.position as usize % self.samples.len()];
self.position += self.freq / self.sample_rate;
sample * self.velocity
}
fn reset(&mut self) {
self.position = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for DpcmChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.position = 0.0;
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dpcm_empty() {
let mut ch = DpcmChannel::new(44100.0);
ch.note_on(8000.0, 1.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_dpcm_playback() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5, 0.5, -0.5]);
ch.note_on(44100.0, 1.0); // 1 sample per tick
assert!((ch.tick() - 0.5).abs() < 0.01);
assert!((ch.tick() - (-0.5)).abs() < 0.01);
}
#[test]
fn test_dpcm_loop() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5]);
ch.set_looping(true);
ch.note_on(44100.0, 1.0); // 1 sample per tick
// tick 1: pos=0 → 0.5, pos→1
assert!((ch.tick() - 0.5).abs() < 0.01);
// tick 2: pos=1 → -0.5, pos→2
assert!((ch.tick() - (-0.5)).abs() < 0.01);
// tick 3: pos=2 ≥ len → wrap to 0 → 0.5, pos→1
assert!((ch.tick() - 0.5).abs() < 0.01);
assert!(ch.is_active());
// tick 4: pos=1 → -0.5
assert!((ch.tick() - (-0.5)).abs() < 0.01);
}
#[test]
fn test_dpcm_no_loop_ends() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5]);
ch.note_on(44100.0, 1.0);
ch.tick();
ch.tick();
ch.tick(); // past end
assert!(!ch.is_active());
}
#[test]
fn test_kick_sample() {
let kick = DpcmChannel::kick();
assert!(!kick.is_empty());
assert!(kick.len() > 500);
}
#[test]
fn test_snare_sample() {
let snare = DpcmChannel::snare();
assert!(!snare.is_empty());
assert!(snare.len() > 500);
}
}
+137
View File
@@ -0,0 +1,137 @@
//! FM channel — 2-operator FM synthesis (carrier + modulator).
//!
//! carrier_out = sin(car_phase + mod_index * sin(mod_phase))
//! mod_freq = car_freq * mod_ratio
use crate::{Generator, Voice};
pub struct FmChannel {
sample_rate: f32,
freq: f32,
car_phase: f32,
mod_phase: f32,
mod_ratio: f32,
mod_index: f32,
velocity: f32,
active: bool,
}
impl FmChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 440.0,
car_phase: 0.0,
mod_phase: 0.0,
mod_ratio: 2.0, // 2:1 ratio (classic FM)
mod_index: 1.0, // moderate modulation
velocity: 0.0,
active: false,
}
}
pub fn set_mod_ratio(&mut self, ratio: f32) {
self.mod_ratio = ratio;
}
pub fn set_mod_index(&mut self, index: f32) {
self.mod_index = index;
}
#[inline]
fn advance(&mut self) {
let car_inc = self.freq / self.sample_rate;
let mod_inc = self.freq * self.mod_ratio / self.sample_rate;
self.car_phase = (self.car_phase + car_inc).fract();
self.mod_phase = (self.mod_phase + mod_inc).fract();
}
}
impl Generator for FmChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
let modulator = self.mod_index * (2.0 * std::f32::consts::PI * self.mod_phase).sin();
let carrier = (2.0 * std::f32::consts::PI * self.car_phase + modulator).sin();
carrier * self.velocity
}
fn reset(&mut self) {
self.car_phase = 0.0;
self.mod_phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for FmChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fm_output_range() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_index(0.0); // no modulation → pure sine
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_fm_inactive() {
let mut ch = FmChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_fm_with_modulation() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_ratio(3.0);
ch.set_mod_index(2.0);
ch.note_on(440.0, 1.0);
// With modulation, output should have harmonics
let mut max_sample = 0.0f32;
for _ in 0..4410 {
let s = ch.tick();
max_sample = max_sample.max(s.abs());
}
assert!(max_sample > 0.5, "FM should produce audible output");
}
#[test]
fn test_fm_zero_mod_is_sine() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_index(0.0);
ch.note_on(440.0, 1.0);
// With 0 mod index, it's a pure sine wave
let s = ch.tick();
// First sample: car_phase ≈ 0, sin(0) ≈ 0
assert!(s.abs() < 0.1);
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Voice implementations: pulse, triangle, noise, dpcm, wavetable, fm.
pub mod dpcm;
pub mod fm;
pub mod noise;
pub mod pulse;
pub mod triangle;
pub mod wavetable;
pub use dpcm::DpcmChannel;
pub use fm::FmChannel;
pub use noise::{NoiseChannel, NoiseMode};
pub use pulse::{DutyCycle, PulseChannel};
pub use triangle::TriangleChannel;
pub use wavetable::WavetableChannel;
/// Discriminated union of all voice types.
/// Avoids dynamic dispatch — dispatch is a `match` in `tick()`.
pub enum VoiceKind {
Pulse(PulseChannel),
Triangle(TriangleChannel),
Noise(NoiseChannel),
Dpcm(DpcmChannel),
Wavetable(WavetableChannel),
Fm(FmChannel),
}
impl crate::Generator for VoiceKind {
#[inline]
fn tick(&mut self) -> f32 {
match self {
VoiceKind::Pulse(v) => v.tick(),
VoiceKind::Triangle(v) => v.tick(),
VoiceKind::Noise(v) => v.tick(),
VoiceKind::Dpcm(v) => v.tick(),
VoiceKind::Wavetable(v) => v.tick(),
VoiceKind::Fm(v) => v.tick(),
}
}
fn reset(&mut self) {
match self {
VoiceKind::Pulse(v) => v.reset(),
VoiceKind::Triangle(v) => v.reset(),
VoiceKind::Noise(v) => v.reset(),
VoiceKind::Dpcm(v) => v.reset(),
VoiceKind::Wavetable(v) => v.reset(),
VoiceKind::Fm(v) => v.reset(),
}
}
}
impl crate::Voice for VoiceKind {
#[inline]
fn note_on(&mut self, freq: f32, velocity: f32) {
match self {
VoiceKind::Pulse(v) => v.note_on(freq, velocity),
VoiceKind::Triangle(v) => v.note_on(freq, velocity),
VoiceKind::Noise(v) => v.note_on(freq, velocity),
VoiceKind::Dpcm(v) => v.note_on(freq, velocity),
VoiceKind::Wavetable(v) => v.note_on(freq, velocity),
VoiceKind::Fm(v) => v.note_on(freq, velocity),
}
}
fn note_off(&mut self) {
match self {
VoiceKind::Pulse(v) => v.note_off(),
VoiceKind::Triangle(v) => v.note_off(),
VoiceKind::Noise(v) => v.note_off(),
VoiceKind::Dpcm(v) => v.note_off(),
VoiceKind::Wavetable(v) => v.note_off(),
VoiceKind::Fm(v) => v.note_off(),
}
}
#[inline]
fn set_frequency(&mut self, freq: f32) {
match self {
VoiceKind::Pulse(v) => v.set_frequency(freq),
VoiceKind::Triangle(v) => v.set_frequency(freq),
VoiceKind::Noise(v) => v.set_frequency(freq),
VoiceKind::Dpcm(v) => v.set_frequency(freq),
VoiceKind::Wavetable(v) => v.set_frequency(freq),
VoiceKind::Fm(v) => v.set_frequency(freq),
}
}
fn is_active(&self) -> bool {
match self {
VoiceKind::Pulse(v) => v.is_active(),
VoiceKind::Triangle(v) => v.is_active(),
VoiceKind::Noise(v) => v.is_active(),
VoiceKind::Dpcm(v) => v.is_active(),
VoiceKind::Wavetable(v) => v.is_active(),
VoiceKind::Fm(v) => v.is_active(),
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Noise channel with LFSR — NES-style percussion.
//!
//! 15-bit Linear Feedback Shift Register.
//! White noise mode: long period (~32767 samples).
//! Periodic mode: short period (~93 samples).
use crate::{Generator, Voice};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoiseMode {
White,
Periodic,
}
pub struct NoiseChannel {
sample_rate: f32,
freq: f32,
phase: f32,
lfsr: u16,
current_bit: f32,
velocity: f32,
active: bool,
mode: NoiseMode,
}
impl NoiseChannel {
pub fn new(sample_rate: f32, mode: NoiseMode) -> Self {
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
lfsr: 1,
current_bit: 1.0,
velocity: 0.0,
active: false,
mode,
}
}
pub fn set_mode(&mut self, mode: NoiseMode) {
self.mode = mode;
}
/// Clock the LFSR one step.
#[inline]
fn clock_lfsr(&mut self) {
let bit0 = (self.lfsr & 1) as u16;
let feedback_bit = match self.mode {
NoiseMode::White => 8, // bit 8
NoiseMode::Periodic => 6, // bit 6
};
let bit_n = (self.lfsr >> feedback_bit) & 1;
let feedback = bit0 ^ bit_n;
self.lfsr >>= 1;
self.lfsr |= feedback << 14;
self.current_bit = if (self.lfsr & 1) == 0 { 1.0 } else { -1.0 };
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
if self.phase >= 1.0 {
self.phase = self.phase.fract();
self.clock_lfsr();
}
}
}
impl Generator for NoiseChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
self.current_bit * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.lfsr = 1;
self.current_bit = 1.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for NoiseChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_noise_output_range() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
ch.note_on(8000.0, 1.0);
for _ in 0..10000 {
let s = ch.tick();
assert!(s == 1.0 || s == -1.0 || s == 0.0);
}
}
#[test]
fn test_noise_inactive() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_lfsr_not_stuck() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
ch.note_on(44100.0, 1.0); // clock every sample
let mut values: Vec<f32> = Vec::new();
for _ in 0..1000 {
values.push(ch.tick());
}
let positives = values.iter().filter(|&&v| v > 0.0).count();
let negatives = values.iter().filter(|&&v| v < 0.0).count();
// Should produce both positive and negative values
assert!(positives > 100 && negatives > 100);
}
#[test]
fn test_periodic_mode_shorter_period() {
let mut white = NoiseChannel::new(44100.0, NoiseMode::White);
white.note_on(44100.0, 1.0);
let mut periodic = NoiseChannel::new(44100.0, NoiseMode::Periodic);
periodic.note_on(44100.0, 1.0);
// Collect unique patterns
let mut white_vals = Vec::new();
let mut periodic_vals = Vec::new();
for _ in 0..200 {
white_vals.push(white.tick());
periodic_vals.push(periodic.tick());
}
// Periodic should have fewer unique transitions (shorter loop)
let white_transitions = white_vals.windows(2).filter(|w| w[0] != w[1]).count();
let periodic_transitions = periodic_vals.windows(2).filter(|w| w[0] != w[1]).count();
// Both should have some transitions
assert!(white_transitions > 0);
assert!(periodic_transitions > 0);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Pulse / square wave channel with selectable duty cycle.
//!
//! Emulates the NES 2A03 pulse channels: phase accumulator + duty comparator.
use crate::{Generator, Voice};
/// NES duty cycle presets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DutyCycle {
D12_5,
D25,
D50,
D75,
}
impl DutyCycle {
pub fn from_percent(p: u8) -> Self {
match p {
0..=12 => DutyCycle::D12_5,
13..=37 => DutyCycle::D25,
38..=62 => DutyCycle::D50,
_ => DutyCycle::D75,
}
}
pub fn as_fraction(self) -> f32 {
match self {
DutyCycle::D12_5 => 0.125,
DutyCycle::D25 => 0.25,
DutyCycle::D50 => 0.5,
DutyCycle::D75 => 0.75,
}
}
}
pub struct PulseChannel {
sample_rate: f32,
freq: f32,
phase: f32,
duty: DutyCycle,
velocity: f32,
active: bool,
}
impl PulseChannel {
pub fn new(sample_rate: f32, duty: DutyCycle) -> Self {
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
duty,
velocity: 0.0,
active: false,
}
}
pub fn set_duty(&mut self, duty: DutyCycle) {
self.duty = duty;
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
}
}
impl Generator for PulseChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
let raw = if self.phase < self.duty.as_fraction() {
1.0
} else {
-1.0
};
raw * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for PulseChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duty_cycle_from_percent() {
assert_eq!(DutyCycle::from_percent(12), DutyCycle::D12_5);
assert_eq!(DutyCycle::from_percent(25), DutyCycle::D25);
assert_eq!(DutyCycle::from_percent(50), DutyCycle::D50);
assert_eq!(DutyCycle::from_percent(75), DutyCycle::D75);
}
#[test]
fn test_pulse_output_range() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D50);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s == 1.0 || s == -1.0 || s == 0.0);
}
}
#[test]
fn test_pulse_inactive() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D50);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_pulse_duty12_5_is_mostly_negative() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D12_5);
ch.note_on(100.0, 1.0);
let mut positive = 0;
let mut negative = 0;
for _ in 0..44100 {
if ch.tick() > 0.0 {
positive += 1;
} else {
negative += 1;
}
}
// 12.5% duty → ~12.5% positive, ~87.5% negative
assert!(positive < negative, "12.5% duty should be mostly negative");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Triangle wave channel — NES-style bass.
//!
//! 32-step triangle via phase accumulator. Linear, no aliasing reduction
//! (acceptable for 8-bit aesthetic).
use crate::{Generator, Voice};
pub struct TriangleChannel {
sample_rate: f32,
freq: f32,
phase: f32,
velocity: f32,
active: bool,
}
impl TriangleChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 220.0,
phase: 0.0,
velocity: 0.0,
active: false,
}
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
}
/// Triangle wave from phase [0, 1).
#[inline]
fn triangle(phase: f32) -> f32 {
if phase < 0.5 {
4.0 * phase - 1.0 // -1 → 1
} else {
3.0 - 4.0 * phase // 1 → -1
}
}
}
impl Generator for TriangleChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
Self::triangle(self.phase) * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for TriangleChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_triangle_waveform() {
assert!((TriangleChannel::triangle(0.0) - (-1.0)).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.25) - 0.0).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.5) - 1.0).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.75) - 0.0).abs() < 1e-6);
}
#[test]
fn test_triangle_output_range() {
let mut ch = TriangleChannel::new(44100.0);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_triangle_inactive() {
let mut ch = TriangleChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Wavetable channel — Game Boy wave-style 32-step wavetable.
//!
//! Each step is a value 0-15 (4-bit), normalized to -1..1.
//! The channel cycles through the wavetable at the given frequency.
use crate::{Generator, Voice};
/// Default Game Boy triangle-like wavetable.
const DEFAULT_WAVE: [u8; 32] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4,
3, 2, 1, 0,
];
pub struct WavetableChannel {
sample_rate: f32,
freq: f32,
phase: f32,
wave: [f32; 32],
velocity: f32,
active: bool,
}
impl WavetableChannel {
pub fn new(sample_rate: f32) -> Self {
let mut wave = [0.0f32; 32];
for (i, &v) in DEFAULT_WAVE.iter().enumerate() {
wave[i] = (v as f32 / 7.5) - 1.0; // 0-15 → -1..1
}
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
wave,
velocity: 0.0,
active: false,
}
}
/// Set a custom wavetable from 4-bit values (0-15).
pub fn set_wave(&mut self, wave: &[u8; 32]) {
for (i, &v) in wave.iter().enumerate() {
self.wave[i] = (v as f32 / 7.5) - 1.0;
}
}
/// Set a custom wavetable from float values (-1..1).
pub fn set_wave_f32(&mut self, wave: &[f32; 32]) {
self.wave = *wave;
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq * 32.0 / self.sample_rate;
self.phase = self.phase.fract();
}
}
impl Generator for WavetableChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
let step = (self.phase * 32.0) as usize % 32;
let sample = self.wave[step] * self.velocity;
self.advance();
sample
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for WavetableChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wavetable_output_range() {
let mut ch = WavetableChannel::new(44100.0);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_wavetable_inactive() {
let mut ch = WavetableChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_custom_wavetable() {
let mut ch = WavetableChannel::new(44100.0);
let mut wave = [0u8; 32];
wave[0] = 15; // peak at step 0
ch.set_wave(&wave);
ch.note_on(100.0, 1.0);
// First sample should be near max (step 0)
let s = ch.tick();
assert!(s > 0.5);
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "soundgen-fmt"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
soundgen-core.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
soundgen-io.workspace = true
soundgen-seq.workspace = true
hound.workspace = true
@@ -0,0 +1,51 @@
//! Example: generate SFX sounds from JSON presets and write to WAV.
use soundgen_fmt::{render_spec, PresetCategory, PresetRegistry};
use soundgen_io::write_wav;
use std::path::Path;
fn main() {
let presets_dir = Path::new("presets");
let registry = PresetRegistry::load_dir(presets_dir).unwrap_or_else(|e| {
eprintln!(
"Warning: could not load presets from {}: {}",
presets_dir.display(),
e
);
PresetRegistry::new()
});
if registry.is_empty() {
eprintln!("No presets found. Run from the project root directory.");
std::process::exit(1);
}
// Generate all SFX presets
let sfx = registry.list(Some(PresetCategory::Sfx));
let ui = registry.list(Some(PresetCategory::Ui));
let out_dir = Path::new("sfx_output");
std::fs::create_dir_all(out_dir).expect("create output dir");
for entry in sfx.iter().chain(ui.iter()) {
let samples = render_spec(&entry.spec);
let filename = format!("{}.wav", entry.name);
let out_path = out_dir.join(&filename);
write_wav(&out_path, &samples, entry.spec.sample_rate)
.unwrap_or_else(|e| eprintln!("Failed to write {}: {}", out_path.display(), e));
eprintln!(
" {} → {} ({} samples, {:.2}s)",
entry.name,
out_path.display(),
samples.len() / 2,
entry.spec.duration
);
}
eprintln!(
"\nGenerated {} sounds to {}",
sfx.len() + ui.len(),
out_dir.display()
);
}
@@ -0,0 +1,91 @@
//! Example: render a simple melody (pulse lead + triangle bass + noise hihat) to WAV.
use soundgen_core::{
voice::{DutyCycle, NoiseChannel, NoiseMode, PulseChannel, TriangleChannel},
ChannelRenderer, Envelope, VoiceKind,
};
use soundgen_io::write_wav;
fn note_freq(n: &str) -> f32 {
// Note name to frequency: e.g., "A4" = 440 Hz
let notes = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let note = &n[..n.len() - 1];
let octave: i32 = n[n.len() - 1..].parse().unwrap();
let semitone = notes.iter().position(|&x| x == note).unwrap() as i32;
let midi = 12 * (octave + 1) + semitone;
440.0 * 2.0f32.powf((midi - 69) as f32 / 12.0)
}
fn main() {
let sr = 44100u32;
let bpm = 120.0;
let beat = 60.0 / bpm; // seconds per beat
let total_beats = 8.0;
let total_samples = (beat * total_beats * sr as f32) as usize;
// Melody: C4 E4 G4 C5 G4 E4 C4 G3
let melody = ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "G3"];
// Bass: C3 C3 G3 G3 A2 A2 F2 F2
let bass = ["C3", "C3", "G3", "G3", "A2", "A2", "F2", "F2"];
let samples_per_note = (beat * sr as f32) as usize;
let mut stereo = Vec::with_capacity(total_samples * 2);
for i in 0..8 {
// Lead: pulse wave
let mut lead = ChannelRenderer::new(
VoiceKind::Pulse(PulseChannel::new(sr as f32, DutyCycle::D50)),
sr as f32,
)
.with_envelope(Envelope::adsr(sr as f32, 0.01, 0.05, 0.6, 0.08))
.with_volume(0.3)
.with_pan(-0.3);
lead.trigger();
lead.set_frequency(note_freq(melody[i]));
// Bass: triangle
let mut bass_ch = ChannelRenderer::new(
VoiceKind::Triangle(TriangleChannel::new(sr as f32)),
sr as f32,
)
.with_envelope(Envelope::adsr(sr as f32, 0.02, 0.1, 0.5, 0.1))
.with_volume(0.35)
.with_pan(0.3);
bass_ch.trigger();
bass_ch.set_frequency(note_freq(bass[i]));
// Hihat: noise on offbeats
let has_hihat = i % 2 == 1;
let mut hihat = ChannelRenderer::new(
VoiceKind::Noise(NoiseChannel::new(sr as f32, NoiseMode::White)),
sr as f32,
)
.with_envelope(Envelope::adsr(sr as f32, 0.0, 0.02, 0.0, 0.03))
.with_volume(0.15)
.with_pan(0.0);
if has_hihat {
hihat.trigger();
hihat.set_frequency(10000.0);
}
for _ in 0..samples_per_note {
let (ll, lr) = lead.tick();
let (bl, br) = bass_ch.tick();
let (hl, hr) = hihat.tick();
let l = (ll + bl + hl).tanh();
let r = (lr + br + hr).tanh();
stereo.push(l);
stereo.push(r);
}
}
let out = std::path::Path::new("play_melody.wav");
write_wav(out, &stereo, sr).expect("failed to write WAV");
eprintln!("Wrote {} ({} notes, {:.1}s)", out.display(), 8, beat * 8.0);
}
+112
View File
@@ -0,0 +1,112 @@
//! Example: render a song (JSON) to WAV using the sequencer.
//!
//! Song JSON format:
//! ```json
//! {
//! "bpm": 120,
//! "rows_per_beat": 4,
//! "tracks": [
//! { "type": "pulse", "duty": 50, "volume": 0.4 }
//! ],
//! "patterns": [
//! { "rows": [ { "notes": [{"frequency": 440}] }, {"notes": [null]} ] }
//! ],
//! "pattern_order": [0]
//! }
//! ```
use soundgen_io::write_wav;
use soundgen_seq::{render_song, Song};
fn main() {
// Build a simple chiptune melody programmatically
let song_json = r#"{
"bpm": 130,
"rows_per_beat": 4,
"sample_rate": 44100,
"tracks": [
{
"type": "pulse", "duty": 50,
"volume": 0.35, "pan": -0.2,
"envelope": { "attack": 0.005, "decay": 0.03, "sustain": 0.6, "release": 0.05 }
},
{
"type": "triangle",
"volume": 0.4, "pan": 0.3,
"envelope": { "attack": 0.01, "decay": 0.05, "sustain": 0.5, "release": 0.08 }
},
{
"type": "noise", "mode": "white", "frequency": 8000,
"volume": 0.15, "pan": 0.0,
"envelope": { "attack": 0.0, "decay": 0.02, "sustain": 0.0, "release": 0.02 }
}
],
"patterns": [
{
"rows": [
{ "notes": [
{"frequency": 523.25, "velocity": 1.0},
{"frequency": 261.63, "velocity": 1.0},
{"frequency": 8000, "velocity": 1.0}
]},
{ "notes": [null, null, null] },
{ "notes": [
{"frequency": 659.25, "velocity": 1.0},
{"frequency": 261.63, "velocity": 1.0},
null
]},
{ "notes": [null, null, {"frequency": 8000, "velocity": 1.0}] },
{ "notes": [
{"frequency": 783.99, "velocity": 1.0},
{"frequency": 329.63, "velocity": 1.0},
null
]},
{ "notes": [null, null, null] },
{ "notes": [
{"frequency": 1046.50, "velocity": 1.0},
{"frequency": 329.63, "velocity": 1.0},
{"frequency": 8000, "velocity": 1.0}
]},
{ "notes": [null, null, null] },
{ "notes": [
{"frequency": 659.25, "velocity": 1.0},
{"frequency": 196.00, "velocity": 1.0},
null
]},
{ "notes": [null, null, {"frequency": 8000, "velocity": 1.0}] },
{ "notes": [
{"frequency": 587.33, "velocity": 1.0},
{"frequency": 196.00, "velocity": 1.0},
null
]},
{ "notes": [null, null, null] },
{ "notes": [
{"frequency": 523.25, "velocity": 1.0},
{"frequency": 261.63, "velocity": 1.0},
{"frequency": 8000, "velocity": 1.0}
]},
{ "notes": [null, null, null] },
{ "notes": [null, null, null] },
{ "notes": [null, null, null] }
]
}
],
"pattern_order": [0, 0]
}"#;
let song: Song = serde_json::from_str(song_json).expect("parse song");
let samples = render_song(&song);
let out = std::path::Path::new("render_song.wav");
write_wav(out, &samples, song.sample_rate).expect("write WAV");
eprintln!(
"Wrote {} ({:.1}s, {} patterns, {} tracks)",
out.display(),
song.duration(),
song.pattern_order.len(),
song.tracks.len()
);
}
+279
View File
@@ -0,0 +1,279 @@
//! SoundSpec — declarative JSON format for describing sounds.
//!
//! LLM-friendly: every sound is a JSON object that can be rendered to WAV.
pub mod preset;
pub mod renderer;
pub use preset::{PresetCategory, PresetEntry, PresetRegistry};
pub use renderer::render_spec;
// Re-export core types that are part of the SoundSpec format
pub use soundgen_core::SweepCurve;
use soundgen_core::FrequencyAutomation;
/// Top-level sound specification.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SoundSpec {
pub name: String,
pub duration: f32,
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
#[serde(default)]
pub channels: Vec<ChannelSpec>,
}
/// ADSR envelope spec.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnvelopeSpec {
#[serde(default)]
pub attack: f32,
#[serde(default)]
pub decay: f32,
#[serde(default = "default_sustain")]
pub sustain: f32,
#[serde(default)]
pub release: f32,
}
/// Filter spec with optional cutoff automation.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct FilterSpec {
#[serde(rename = "type")]
pub kind: FilterKind,
#[serde(default = "default_cutoff")]
pub cutoff: f32,
/// Optional cutoff sweep. If present, `cutoff` is the start value.
#[serde(default)]
pub cutoff_sweep: Option<CutoffAutomation>,
#[serde(default = "default_q")]
pub q: f32,
}
/// Cutoff frequency automation (start → end over the sound's duration).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CutoffAutomation {
pub start: f32,
pub end: f32,
#[serde(default = "default_curve")]
pub curve: soundgen_core::SweepCurve,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilterKind {
Lowpass,
Highpass,
}
/// Channel specification — discriminated by `type` field.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ChannelSpec {
Pulse {
/// Duty cycle percent (12, 25, 50, 75).
#[serde(default = "default_duty")]
duty: u8,
#[serde(default)]
frequency: FrequencyAutomation,
#[serde(default)]
envelope: Option<EnvelopeSpec>,
#[serde(default)]
filter: Option<FilterSpec>,
#[serde(default = "default_volume")]
volume: f32,
#[serde(default)]
pan: f32,
},
Triangle {
#[serde(default)]
frequency: FrequencyAutomation,
#[serde(default)]
envelope: Option<EnvelopeSpec>,
#[serde(default)]
filter: Option<FilterSpec>,
#[serde(default = "default_volume")]
volume: f32,
#[serde(default)]
pan: f32,
},
Noise {
/// "white" or "periodic".
#[serde(default = "default_noise_mode")]
mode: String,
/// Base frequency for the noise clock.
#[serde(default = "default_noise_freq")]
frequency: f32,
#[serde(default)]
envelope: Option<EnvelopeSpec>,
#[serde(default)]
filter: Option<FilterSpec>,
#[serde(default = "default_volume")]
volume: f32,
#[serde(default)]
pan: f32,
},
}
// Defaults
fn default_sample_rate() -> u32 {
44100
}
fn default_sustain() -> f32 {
0.7
}
fn default_cutoff() -> f32 {
5000.0
}
fn default_q() -> f32 {
0.707
}
fn default_curve() -> soundgen_core::SweepCurve {
soundgen_core::SweepCurve::Linear
}
fn default_duty() -> u8 {
50
}
fn default_volume() -> f32 {
0.7
}
fn default_noise_mode() -> String {
"white".to_string()
}
fn default_noise_freq() -> f32 {
8000.0
}
impl Default for EnvelopeSpec {
fn default() -> Self {
Self {
attack: 0.01,
decay: 0.1,
sustain: 0.0,
release: 0.1,
}
}
}
impl Default for SoundSpec {
fn default() -> Self {
Self {
name: String::new(),
duration: 0.2,
sample_rate: 44100,
channels: vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pulse_channel() {
let json = r#"{
"type": "pulse",
"duty": 50,
"frequency": { "start": 200, "end": 800, "curve": "exponential" },
"envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 },
"volume": 0.7
}"#;
let ch: ChannelSpec = serde_json::from_str(json).unwrap();
match ch {
ChannelSpec::Pulse { duty, volume, .. } => {
assert_eq!(duty, 50);
assert!((volume - 0.7).abs() < 0.01);
}
_ => panic!("expected Pulse"),
}
}
#[test]
fn test_parse_noise_channel() {
let json = r#"{
"type": "noise",
"mode": "white",
"filter": { "type": "lowpass", "cutoff": 2000, "cutoff_sweep": { "start": 2000, "end": 200, "curve": "exponential" } },
"envelope": { "attack": 0.005, "decay": 0.7, "sustain": 0.0, "release": 0.095 },
"volume": 0.9
}"#;
let ch: ChannelSpec = serde_json::from_str(json).unwrap();
match ch {
ChannelSpec::Noise { mode, filter, .. } => {
assert_eq!(mode, "white");
assert!(filter.is_some());
}
_ => panic!("expected Noise"),
}
}
#[test]
fn test_parse_full_spec() {
let json = r#"{
"name": "jump",
"duration": 0.3,
"sample_rate": 44100,
"channels": [
{
"type": "pulse",
"duty": 50,
"frequency": { "start": 200, "end": 800, "curve": "exponential" },
"envelope": { "attack": 0.01, "decay": 0.15, "sustain": 0.0, "release": 0.14 },
"volume": 0.7
}
]
}"#;
let spec: SoundSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.name, "jump");
assert!((spec.duration - 0.3).abs() < 0.001);
assert_eq!(spec.channels.len(), 1);
}
#[test]
fn test_parse_with_defaults() {
let json = r#"{
"name": "test",
"duration": 0.1,
"channels": [
{ "type": "triangle", "frequency": { "start": 220, "end": 220 } }
]
}"#;
let spec: SoundSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.sample_rate, 44100); // default
match &spec.channels[0] {
ChannelSpec::Triangle { volume, .. } => {
assert!((volume - 0.7).abs() < 0.01); // default
}
_ => panic!("expected Triangle"),
}
}
#[test]
fn test_spec_serialization_roundtrip() {
let spec = SoundSpec {
name: "test".to_string(),
duration: 0.5,
sample_rate: 48000,
channels: vec![ChannelSpec::Pulse {
duty: 25,
frequency: FrequencyAutomation::fixed(440.0),
envelope: Some(EnvelopeSpec {
attack: 0.01,
decay: 0.1,
sustain: 0.5,
release: 0.2,
}),
filter: None,
volume: 0.8,
pan: -0.5,
}],
};
let json = serde_json::to_string_pretty(&spec).unwrap();
let spec2: SoundSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec2.name, spec.name);
assert!((spec2.duration - spec.duration).abs() < 0.001);
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Preset registry — loads and manages sound presets from JSON files.
//!
//! Presets are JSON data files in `presets/{sfx,ui,ambient}/`.
use std::fs;
use std::path::{Path, PathBuf};
use crate::SoundSpec;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PresetCategory {
Sfx,
Ui,
Ambient,
}
impl PresetCategory {
pub fn as_str(&self) -> &'static str {
match self {
PresetCategory::Sfx => "sfx",
PresetCategory::Ui => "ui",
PresetCategory::Ambient => "ambient",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"sfx" => Some(PresetCategory::Sfx),
"ui" => Some(PresetCategory::Ui),
"ambient" => Some(PresetCategory::Ambient),
_ => None,
}
}
}
/// A loaded preset with its category and source path.
#[derive(Clone, Debug)]
pub struct PresetEntry {
pub name: String,
pub category: PresetCategory,
pub spec: SoundSpec,
pub source: PathBuf,
}
pub struct PresetRegistry {
presets: Vec<PresetEntry>,
}
impl PresetRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self { presets: vec![] }
}
/// Load all presets from a directory tree.
///
/// Expected layout: `base/{sfx,ui,ambient}/*.json`
pub fn load_dir(base: &Path) -> Result<Self, String> {
let mut registry = Self::new();
for category in [
PresetCategory::Sfx,
PresetCategory::Ui,
PresetCategory::Ambient,
] {
let dir = base.join(category.as_str());
if !dir.exists() {
continue;
}
registry.load_category_dir(&dir, category)?;
}
Ok(registry)
}
fn load_category_dir(&mut self, dir: &Path, category: PresetCategory) -> Result<(), String> {
let entries =
fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("dir entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
self.load_file(&path, category)?;
}
}
Ok(())
}
fn load_file(&mut self, path: &Path, category: PresetCategory) -> Result<(), String> {
let content =
fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?;
let spec: SoundSpec = serde_json::from_str(&content)
.map_err(|e| format!("parse {}: {}", path.display(), e))?;
self.presets.push(PresetEntry {
name: spec.name.clone(),
category,
spec,
source: path.to_path_buf(),
});
Ok(())
}
/// Register a single spec by name and category.
pub fn register(&mut self, name: &str, category: PresetCategory, spec: SoundSpec) {
self.presets.push(PresetEntry {
name: name.to_string(),
category,
spec,
source: PathBuf::new(),
});
}
/// Find a preset by name (case-insensitive).
pub fn get(&self, name: &str) -> Option<&PresetEntry> {
self.presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
}
/// Find a preset by name and category.
pub fn get_in_category(&self, name: &str, category: PresetCategory) -> Option<&PresetEntry> {
self.presets
.iter()
.find(|p| p.category == category && p.name.eq_ignore_ascii_case(name))
}
/// List all presets, optionally filtered by category.
pub fn list(&self, category: Option<PresetCategory>) -> Vec<&PresetEntry> {
self.presets
.iter()
.filter(|p| category.map_or(true, |c| p.category == c))
.collect()
}
/// List preset names.
pub fn names(&self) -> Vec<String> {
self.presets.iter().map(|p| p.name.clone()).collect()
}
pub fn len(&self) -> usize {
self.presets.len()
}
pub fn is_empty(&self) -> bool {
self.presets.is_empty()
}
}
impl Default for PresetRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ChannelSpec;
use soundgen_core::FrequencyAutomation;
#[test]
fn test_register_and_get() {
let mut reg = PresetRegistry::new();
let spec = SoundSpec {
name: "test_sound".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,
}],
};
reg.register("test_sound", PresetCategory::Sfx, spec);
assert!(reg.get("test_sound").is_some());
assert!(reg.get("TEST_SOUND").is_some()); // case-insensitive
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn test_list_by_category() {
let mut reg = PresetRegistry::new();
let make_spec = |name: &str| SoundSpec {
name: name.to_string(),
duration: 0.1,
sample_rate: 44100,
channels: vec![],
};
reg.register("a", PresetCategory::Sfx, make_spec("a"));
reg.register("b", PresetCategory::Sfx, make_spec("b"));
reg.register("c", PresetCategory::Ui, make_spec("c"));
assert_eq!(reg.list(None).len(), 3);
assert_eq!(reg.list(Some(PresetCategory::Sfx)).len(), 2);
assert_eq!(reg.list(Some(PresetCategory::Ui)).len(), 1);
assert_eq!(reg.list(Some(PresetCategory::Ambient)).len(), 0);
}
#[test]
fn test_load_dir() {
let tmp = std::env::temp_dir().join("soundgen_test_presets");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("sfx")).unwrap();
let spec_json = r#"{
"name": "test_jump",
"duration": 0.2,
"channels": [
{ "type": "pulse", "duty": 50, "frequency": { "start": 200, "end": 600 } }
]
}"#;
std::fs::write(tmp.join("sfx").join("jump.json"), spec_json).unwrap();
let reg = PresetRegistry::load_dir(&tmp).unwrap();
assert_eq!(reg.len(), 1);
assert!(reg.get("test_jump").is_some());
let _ = std::fs::remove_dir_all(&tmp);
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Render a [`SoundSpec`] to interleaved stereo `Vec<f32>`.
use soundgen_core::{
ChannelRenderer, Envelope, Filter, FilterType, FrequencyAutomation, NoiseMode, Sweep, VoiceKind,
};
use VoiceKind as VK;
use crate::{ChannelSpec, EnvelopeSpec, FilterSpec, SoundSpec};
/// Render a [`SoundSpec`] to interleaved stereo samples (L, R, L, R, ...).
pub fn render_spec(spec: &SoundSpec) -> Vec<f32> {
let sr = spec.sample_rate as f32;
let n_samples = (spec.duration * sr).ceil() as usize;
let mut channels: Vec<ChannelRenderer> = spec
.channels
.iter()
.map(|ch| build_channel(ch, sr, spec.duration))
.collect();
for ch in &mut channels {
ch.trigger();
}
soundgen_core::render_channels(&mut channels, n_samples)
}
fn build_channel(spec: &ChannelSpec, sr: f32, duration: f32) -> ChannelRenderer {
match spec {
ChannelSpec::Pulse {
duty,
frequency,
envelope,
filter,
volume,
pan,
} => {
let duty_cycle = soundgen_core::voice::DutyCycle::from_percent(*duty);
let voice = VK::Pulse(soundgen_core::voice::PulseChannel::new(sr, duty_cycle));
build_renderer(
voice, sr, duration, frequency, envelope, filter, *volume, *pan,
)
}
ChannelSpec::Triangle {
frequency,
envelope,
filter,
volume,
pan,
} => {
let voice = VK::Triangle(soundgen_core::voice::TriangleChannel::new(sr));
build_renderer(
voice, sr, duration, frequency, envelope, filter, *volume, *pan,
)
}
ChannelSpec::Noise {
mode,
frequency,
envelope,
filter,
volume,
pan,
} => {
let noise_mode = match mode.as_str() {
"periodic" => NoiseMode::Periodic,
_ => NoiseMode::White,
};
let noise = soundgen_core::voice::NoiseChannel::new(sr, noise_mode);
let voice = VK::Noise(noise);
// Noise "frequency" is a static clock rate — wrap as fixed automation.
let freq_auto = FrequencyAutomation::fixed(*frequency);
build_renderer(
voice, sr, duration, &freq_auto, envelope, filter, *volume, *pan,
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_renderer(
voice: VoiceKind,
sr: f32,
duration: f32,
frequency: &FrequencyAutomation,
envelope: &Option<EnvelopeSpec>,
filter: &Option<FilterSpec>,
volume: f32,
pan: f32,
) -> ChannelRenderer {
let mut cr = ChannelRenderer::new(voice, sr)
.with_volume(volume)
.with_pan(pan)
.with_initial_frequency(frequency.start);
// Frequency sweep (only if frequency changes over time)
if !frequency.is_static() {
cr = cr.with_freq_sweep(frequency.to_sweep(sr, duration));
}
// Envelope
if let Some(env) = envelope {
cr = cr.with_envelope(Envelope::adsr(
sr,
env.attack,
env.decay,
env.sustain,
env.release,
));
}
// Filter
if let Some(filt) = filter {
let ft = match filt.kind {
crate::FilterKind::Lowpass => FilterType::Lowpass,
crate::FilterKind::Highpass => FilterType::Highpass,
};
let cutoff_start = filt
.cutoff_sweep
.as_ref()
.map(|s| s.start)
.unwrap_or(filt.cutoff);
let filter = Filter::new(sr, ft, cutoff_start, filt.q);
cr = cr.with_filter(filter);
// Filter cutoff sweep
if let Some(cs) = &filt.cutoff_sweep {
let sweep = Sweep::new(sr, cs.start, cs.end, cs.curve, duration);
cr = cr.with_filter_sweep(sweep);
}
}
cr
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_simple_spec() {
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: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
}),
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
let out = render_spec(&spec);
// Duration 0.1s * 44100 = 4410 samples → 8820 interleaved
assert_eq!(out.len(), 8820);
// Should have non-zero samples
let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count();
assert!(non_zero > 100, "expected non-zero output");
}
#[test]
fn test_render_noise_spec() {
let spec = SoundSpec {
name: "noise_test".to_string(),
duration: 0.05,
sample_rate: 44100,
channels: vec![ChannelSpec::Noise {
mode: "white".to_string(),
frequency: 8000.0,
envelope: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.05,
sustain: 0.0,
release: 0.0,
}),
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
let out = render_spec(&spec);
assert_eq!(out.len(), 4410);
// Noise should produce some non-zero samples
let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count();
assert!(non_zero > 0);
}
#[test]
fn test_render_empty_spec() {
let spec = SoundSpec {
name: "empty".to_string(),
duration: 0.1,
sample_rate: 44100,
channels: vec![],
};
let out = render_spec(&spec);
assert_eq!(out.len(), 8820);
// All zeros (no channels)
assert!(out.iter().all(|&s| s == 0.0));
}
#[test]
fn test_render_with_frequency_sweep() {
let spec = SoundSpec {
name: "sweep".to_string(),
duration: 0.1,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: FrequencyAutomation {
start: 100.0,
end: 1000.0,
curve: soundgen_core::SweepCurve::Exponential,
},
envelope: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
}),
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
let out = render_spec(&spec);
assert_eq!(out.len(), 8820);
// Should produce output
let non_zero = out.iter().filter(|&&s| s.abs() > 0.01).count();
assert!(non_zero > 100);
}
#[test]
fn test_render_with_filter() {
let spec = SoundSpec {
name: "filtered".to_string(),
duration: 0.1,
sample_rate: 44100,
channels: vec![ChannelSpec::Noise {
mode: "white".to_string(),
frequency: 8000.0,
envelope: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
}),
filter: Some(FilterSpec {
kind: crate::FilterKind::Lowpass,
cutoff: 500.0,
cutoff_sweep: None,
q: 0.707,
}),
volume: 0.5,
pan: 0.0,
}],
};
let out = render_spec(&spec);
assert_eq!(out.len(), 8820);
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Integration tests: end-to-end render → WAV verification.
use hound;
use soundgen_core::{FrequencyAutomation, SweepCurve};
use soundgen_fmt::{
render_spec, ChannelSpec, CutoffAutomation, EnvelopeSpec, FilterKind, FilterSpec,
PresetCategory, PresetRegistry, SoundSpec,
};
use soundgen_io::write_wav;
/// Get the workspace root path (presets/ directory).
fn workspace_root() -> &'static str {
concat!(env!("CARGO_MANIFEST_DIR"), "/../..")
}
fn rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum: f32 = samples.iter().map(|s| s * s).sum();
(sum / samples.len() as f32).sqrt()
}
#[test]
fn test_render_spec_to_wav_and_verify() {
let spec = SoundSpec {
name: "integration_test".to_string(),
duration: 0.2,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: FrequencyAutomation {
start: 200.0,
end: 800.0,
curve: SweepCurve::Exponential,
},
envelope: Some(EnvelopeSpec {
attack: 0.005,
decay: 0.05,
sustain: 0.3,
release: 0.1,
}),
filter: None,
volume: 0.6,
pan: 0.0,
}],
};
let samples = render_spec(&spec);
// 0.2s * 44100 = 8820 frames -> 17640 interleaved stereo samples
assert_eq!(samples.len(), 17640);
let mono: Vec<f32> = samples.iter().step_by(2).cloned().collect();
let r = rms(&mono);
assert!(r > 0.01, "RMS too low: {}", r);
let path = std::env::temp_dir().join("soundgen_integration.wav");
write_wav(&path, &samples, 44100).unwrap();
assert!(path.exists());
let reader = hound::WavReader::open(&path).unwrap();
let wav_spec = reader.spec();
assert_eq!(wav_spec.channels, 2);
assert_eq!(wav_spec.sample_rate, 44100);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_all_presets_render_successfully() {
let presets_dir = std::path::Path::new(workspace_root()).join("presets");
let registry = PresetRegistry::load_dir(&presets_dir).expect("failed to load presets");
assert!(
registry.len() >= 10,
"expected at least 10 presets, got {}",
registry.len()
);
for entry in registry.list(None) {
let samples = render_spec(&entry.spec);
let expected_len =
(entry.spec.duration * entry.spec.sample_rate as f32).ceil() as usize * 2;
assert_eq!(
samples.len(),
expected_len,
"preset '{}' has wrong sample count",
entry.name
);
let mono: Vec<f32> = samples.iter().step_by(2).cloned().collect();
let r = rms(&mono);
assert!(
r > 0.001,
"preset '{}' has near-zero RMS: {}",
entry.name,
r
);
}
}
#[test]
fn test_sfx_and_ui_categories_present() {
let presets_dir = std::path::Path::new(workspace_root()).join("presets");
let registry = PresetRegistry::load_dir(&presets_dir).unwrap();
let sfx = registry.list(Some(PresetCategory::Sfx));
let ui = registry.list(Some(PresetCategory::Ui));
assert!(sfx.len() >= 6, "expected at least 6 SFX presets");
assert!(ui.len() >= 4, "expected at least 4 UI presets");
assert!(registry.get("jump").is_some());
assert!(registry.get("explosion").is_some());
assert!(registry.get("click").is_some());
assert!(registry.get("confirm").is_some());
}
#[test]
fn test_json_spec_roundtrip() {
let spec = SoundSpec {
name: "roundtrip".to_string(),
duration: 0.3,
sample_rate: 48000,
channels: vec![ChannelSpec::Noise {
mode: "white".to_string(),
frequency: 8000.0,
envelope: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.2,
sustain: 0.0,
release: 0.1,
}),
filter: Some(FilterSpec {
kind: FilterKind::Lowpass,
cutoff: 2000.0,
cutoff_sweep: Some(CutoffAutomation {
start: 2000.0,
end: 200.0,
curve: SweepCurve::Exponential,
}),
q: 0.707,
}),
volume: 0.7,
pan: 0.0,
}],
};
let json = serde_json::to_string_pretty(&spec).unwrap();
let spec2: SoundSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec2.name, spec.name);
assert!((spec2.duration - spec.duration).abs() < 0.001);
assert_eq!(spec2.sample_rate, spec.sample_rate);
assert_eq!(spec2.channels.len(), spec.channels.len());
}
#[test]
fn test_wav_file_format_correct() {
let spec = SoundSpec {
name: "wav_format_test".to_string(),
duration: 0.05,
sample_rate: 22050,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: FrequencyAutomation::fixed(440.0),
envelope: Some(EnvelopeSpec {
attack: 0.0,
decay: 0.0,
sustain: 1.0,
release: 0.0,
}),
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
let samples = render_spec(&spec);
let path = std::env::temp_dir().join("soundgen_wav_format_test.wav");
write_wav(&path, &samples, 22050).unwrap();
let reader = hound::WavReader::open(&path).unwrap();
let wav_spec = reader.spec();
assert_eq!(wav_spec.channels, 2);
assert_eq!(wav_spec.sample_rate, 22050);
assert_eq!(wav_spec.bits_per_sample, 16);
// 0.05 * 22050 = 1102.5, ceil = 1103 frames, * 2 channels = 2206 samples
let sample_count = reader.into_samples::<i16>().count();
assert_eq!(sample_count, 2206);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_multi_channel_render() {
let spec = SoundSpec {
name: "multi".to_string(),
duration: 0.1,
sample_rate: 44100,
channels: vec![
ChannelSpec::Pulse {
duty: 50,
frequency: FrequencyAutomation::fixed(440.0),
envelope: Some(EnvelopeSpec::default()),
filter: None,
volume: 0.5,
pan: -0.5,
},
ChannelSpec::Triangle {
frequency: FrequencyAutomation::fixed(220.0),
envelope: Some(EnvelopeSpec::default()),
filter: None,
volume: 0.4,
pan: 0.5,
},
ChannelSpec::Noise {
mode: "white".to_string(),
frequency: 5000.0,
envelope: Some(EnvelopeSpec::default()),
filter: None,
volume: 0.3,
pan: 0.0,
},
],
};
let samples = render_spec(&spec);
// 0.1 * 44100 = 4410 frames -> 8820 interleaved
assert_eq!(samples.len(), 8820);
let left: Vec<f32> = samples.iter().step_by(2).cloned().collect();
let right: Vec<f32> = samples.iter().skip(1).step_by(2).cloned().collect();
assert!(rms(&left) > 0.01);
assert!(rms(&right) > 0.01);
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "soundgen-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "soundgen-gui"
path = "src/main.rs"
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
soundgen-io.workspace = true
soundgen-seq.workspace = true
eframe.workspace = true
egui.workspace = true
egui-file-dialog.workspace = true
serde_json.workspace = true
+912
View File
@@ -0,0 +1,912 @@
//! Main Soundgen GUI application.
//!
//! Layout:
//! - Top: menu bar (File / Edit)
//! - Below: tab bar (SFX Editor / Sequencer) + transport (Play/Stop)
//! - Left: preset browser
//! - Center: editor content
//! - Bottom: status bar + virtual keyboard
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use eframe::egui;
use egui_file_dialog::FileDialog;
use soundgen_fmt::{render_spec, ChannelSpec, PresetRegistry, SoundSpec};
use soundgen_io::{play, write_wav};
use soundgen_seq::{render_song, Song};
use crate::channel_panel::channel_panel;
use crate::keyboard::Keyboard;
use crate::preset_browser::PresetBrowser;
use crate::waveform::waveform_display;
// ── Undo/Redo ────────────────────────────────────────────
struct UndoStack {
undo: Vec<SoundSpec>,
redo: Vec<SoundSpec>,
last_save: Instant,
}
impl UndoStack {
fn new() -> Self {
Self {
undo: vec![],
redo: vec![],
last_save: Instant::now(),
}
}
fn push(&mut self, spec: &SoundSpec) {
// Debounce: don't push more often than every 500ms
if self.last_save.elapsed() < Duration::from_millis(500) {
if let Some(last) = self.undo.last_mut() {
*last = spec.clone();
return;
}
}
self.last_save = Instant::now();
self.undo.push(spec.clone());
self.redo.clear();
if self.undo.len() > 50 {
self.undo.remove(0);
}
}
fn undo(&mut self, current: &SoundSpec) -> Option<SoundSpec> {
if let Some(prev) = self.undo.pop() {
self.redo.push(current.clone());
Some(prev)
} else {
None
}
}
fn redo(&mut self, current: &SoundSpec) -> Option<SoundSpec> {
if let Some(next) = self.redo.pop() {
self.undo.push(current.clone());
Some(next)
} else {
None
}
}
fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
}
// ── App State ────────────────────────────────────────────
pub struct SoundgenApp {
spec: SoundSpec,
registry: PresetRegistry,
browser: PresetBrowser,
keyboard: Keyboard,
song: Song,
song_json: String,
song_json_error: Option<String>,
preview_samples: Vec<f32>,
preview_dirty: bool,
last_preview_update: Option<Instant>,
status: String,
status_time: Option<Instant>,
playing: Option<Arc<Mutex<soundgen_io::PlaybackHandle>>>,
tab: Tab,
file_path: String,
undo: UndoStack,
file_dialog: FileDialog,
export_dialog: FileDialog,
open_dialog: FileDialog,
last_keyboard_freq: f32,
last_keyboard_vel: u8,
}
#[derive(PartialEq)]
enum Tab {
SfxEditor,
Sequencer,
}
impl Default for SoundgenApp {
fn default() -> Self {
let registry =
PresetRegistry::load_dir(std::path::Path::new("presets")).unwrap_or_else(|e| {
eprintln!("Warning: could not load presets: {}", e);
PresetRegistry::new()
});
let spec = registry
.get("jump")
.map(|e| e.spec.clone())
.unwrap_or(SoundSpec {
name: "new_sound".to_string(),
duration: 0.2,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: soundgen_core::FrequencyAutomation::fixed(440.0),
envelope: None,
filter: None,
volume: 0.5,
pan: 0.0,
}],
});
let song = default_song();
let song_json = serde_json::to_string_pretty(&song).unwrap_or_default();
Self {
spec,
registry,
browser: PresetBrowser::new(),
keyboard: Keyboard::new(60, 2),
song,
song_json,
song_json_error: None,
preview_samples: vec![],
preview_dirty: true,
last_preview_update: None,
status: "Ready".to_string(),
status_time: None,
playing: None,
tab: Tab::SfxEditor,
file_path: "sound.json".to_string(),
undo: UndoStack::new(),
file_dialog: FileDialog::default(),
export_dialog: FileDialog::default(),
open_dialog: FileDialog::default(),
last_keyboard_freq: 440.0,
last_keyboard_vel: 100,
}
}
}
fn default_song() -> Song {
use soundgen_seq::{EnvelopeConfig, Note, Pattern, Row, TrackConfig, TrackVoice};
Song {
bpm: 120.0,
rows_per_beat: 4,
sample_rate: 44100,
tracks: vec![
TrackConfig {
voice: TrackVoice::Pulse { duty: 50 },
envelope: Some(EnvelopeConfig {
attack: 0.005,
decay: 0.03,
sustain: 0.6,
release: 0.05,
}),
volume: 0.35,
pan: -0.2,
},
TrackConfig {
voice: TrackVoice::Triangle,
envelope: Some(EnvelopeConfig {
attack: 0.01,
decay: 0.05,
sustain: 0.5,
release: 0.08,
}),
volume: 0.4,
pan: 0.3,
},
],
patterns: vec![Pattern {
rows: vec![
Row {
notes: vec![
Some(Note::from_name("C4", 1.0).unwrap()),
Some(Note::from_name("C2", 1.0).unwrap()),
],
},
Row {
notes: vec![None, None],
},
Row {
notes: vec![Some(Note::from_name("E4", 1.0).unwrap()), None],
},
Row {
notes: vec![None, None],
},
Row {
notes: vec![
Some(Note::from_name("G4", 1.0).unwrap()),
Some(Note::from_name("G2", 1.0).unwrap()),
],
},
Row {
notes: vec![None, None],
},
Row {
notes: vec![Some(Note::from_name("E4", 1.0).unwrap()), None],
},
Row {
notes: vec![None, None],
},
],
}],
pattern_order: vec![0, 0],
}
}
impl eframe::App for SoundgenApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Handle hotkeys
self.handle_hotkeys(ctx);
// Debounced preview update
if self.preview_dirty {
if let Some(last) = self.last_preview_update {
if last.elapsed() >= Duration::from_millis(100) {
self.update_preview();
self.preview_dirty = false;
self.last_preview_update = None;
} else {
ctx.request_repaint_after(Duration::from_millis(50));
}
} else {
self.last_preview_update = Some(Instant::now());
ctx.request_repaint_after(Duration::from_millis(100));
}
}
// Menu bar
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Open... (Ctrl+O)").clicked() {
self.open_dialog.select_file();
ui.close_menu();
}
if ui.button("Save... (Ctrl+S)").clicked() {
self.file_dialog.save_file();
ui.close_menu();
}
if ui.button("Export WAV... (Ctrl+E)").clicked() {
self.export_dialog.save_file();
ui.close_menu();
}
if ui.button("New Sound").clicked() {
self.new_sound();
ui.close_menu();
}
});
ui.menu_button("Edit", |ui| {
ui.add_enabled_ui(self.undo.can_undo(), |ui| {
if ui.button("Undo (Ctrl+Z)").clicked() {
self.do_undo();
ui.close_menu();
}
});
ui.add_enabled_ui(self.undo.can_redo(), |ui| {
if ui.button("Redo (Ctrl+Shift+Z)").clicked() {
self.do_redo();
ui.close_menu();
}
});
});
ui.menu_button("Add Channel", |ui| {
if ui.button("Pulse").clicked() {
self.add_channel(ChannelType::Pulse);
ui.close_menu();
}
if ui.button("Triangle").clicked() {
self.add_channel(ChannelType::Triangle);
ui.close_menu();
}
if ui.button("Noise").clicked() {
self.add_channel(ChannelType::Noise);
ui.close_menu();
}
});
ui.separator();
ui.selectable_value(&mut self.tab, Tab::SfxEditor, "SFX Editor");
ui.selectable_value(&mut self.tab, Tab::Sequencer, "Sequencer");
ui.separator();
// Transport
let play_label = if self.playing.is_some() {
"⏸ Stop"
} else {
"▶ Play"
};
if ui.button(play_label).clicked() {
if self.playing.is_some() {
self.stop_playback();
self.set_status("Stopped".to_string());
} else {
self.play_current();
}
}
});
});
// Status bar (above keyboard)
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
let status_color = if self.status.starts_with("Error") {
egui::Color32::from_rgb(255, 100, 100)
} else {
egui::Color32::from_rgb(120, 180, 120)
};
ui.label(egui::RichText::new(&self.status).color(status_color));
ui.separator();
ui.label(
egui::RichText::new(format!("{} Hz", self.spec.sample_rate))
.small()
.weak(),
);
if self.last_keyboard_freq > 0.0 {
ui.label(
egui::RichText::new(format!("Note: {:.0} Hz", self.last_keyboard_freq))
.small()
.weak(),
);
}
ui.separator();
ui.label(
egui::RichText::new(format!("Channels: {}", self.spec.channels.len()))
.small()
.weak(),
);
if self.undo.can_undo() {
ui.label(
egui::RichText::new(format!("Undo: {}", self.undo.undo.len()))
.small()
.weak(),
);
}
});
});
// Keyboard panel
egui::TopBottomPanel::bottom("keyboard_panel")
.resizable(true)
.default_height(140.0)
.show(ctx, |ui| {
let (presses, _releases, vel) = self.keyboard.show(ui);
for midi in &presses {
let freq = crate::keyboard::midi_to_freq(*midi);
self.last_keyboard_freq = freq;
self.last_keyboard_vel = vel;
// Set frequency on first channel
if !self.spec.channels.is_empty() {
self.spec.channels[0] =
set_channel_freq(self.spec.channels[0].clone(), freq);
// Set velocity as volume
self.spec.channels[0] =
set_channel_vol(self.spec.channels[0].clone(), vel as f32 / 127.0);
}
}
if !presses.is_empty() {
self.play_current();
}
});
// Left panel: preset browser
egui::SidePanel::left("browser")
.resizable(true)
.default_width(260.0)
.show(ctx, |ui| {
let to_play = self.browser.show(ui, &self.registry);
if let Some(name) = to_play {
if let Some(entry) = self.registry.get(&name) {
self.undo.push(&self.spec);
self.spec = entry.spec.clone();
self.preview_dirty = true;
self.status = format!("Loaded: {}", name);
self.play_current();
}
}
ui.separator();
let selected_name = self.browser.selected.clone();
if let Some(name) = &selected_name {
let entry = self.registry.get(name).cloned();
if let Some(entry) = entry {
let spec = entry.spec.clone();
let display_name = entry.name.clone();
let dur = entry.spec.duration;
let nch = entry.spec.channels.len();
let sr = entry.spec.sample_rate;
ui.group(|ui| {
ui.heading(display_name);
ui.label(format!("Duration: {:.2}s", dur));
ui.label(format!("Channels: {}", nch));
if ui.button("Load into editor").clicked() {
self.undo.push(&self.spec);
self.spec = spec.clone();
self.preview_dirty = true;
self.status = format!("Loaded: {}", name);
}
if ui.button("▶ Play preset").clicked() {
let samples = render_spec(&spec);
self.start_playback(samples, sr);
}
});
}
}
});
// Central panel: editor
egui::CentralPanel::default().show(ctx, |ui| match self.tab {
Tab::SfxEditor => self.show_sfx_editor(ui),
Tab::Sequencer => self.show_sequencer(ui),
});
// Handle file dialogs
self.handle_file_dialogs(ctx);
}
}
#[derive(Clone, Copy)]
enum ChannelType {
Pulse,
Triangle,
Noise,
}
impl SoundgenApp {
fn handle_hotkeys(&mut self, ctx: &egui::Context) {
let modifiers = ctx.input(|i| i.modifiers);
let ctrl = modifiers.ctrl || modifiers.command;
// Space: play/stop
if ctx.input(|i| i.key_pressed(egui::Key::Space)) {
if self.playing.is_some() {
self.stop_playback();
self.set_status("Stopped".to_string());
} else {
self.play_current();
}
}
// Ctrl+S: save
if ctrl && ctx.input(|i| i.key_pressed(egui::Key::S)) {
self.file_dialog.save_file();
}
// Ctrl+O: open
if ctrl && ctx.input(|i| i.key_pressed(egui::Key::O)) {
self.open_dialog.select_file();
}
// Ctrl+E: export WAV
if ctrl && ctx.input(|i| i.key_pressed(egui::Key::E)) {
self.export_dialog.save_file();
}
// Ctrl+Z: undo, Ctrl+Shift+Z: redo
if ctrl && ctx.input(|i| i.key_pressed(egui::Key::Z)) {
if modifiers.shift {
self.do_redo();
} else {
self.do_undo();
}
}
}
fn handle_file_dialogs(&mut self, ctx: &egui::Context) {
// Save dialog
self.file_dialog.update(ctx);
if let Some(path) = self.file_dialog.take_selected() {
let json = serde_json::to_string_pretty(&self.spec).unwrap_or_default();
match std::fs::write(&path, json) {
Ok(()) => {
self.file_path = path.display().to_string();
self.set_status(format!("Saved to {}", path.display()));
}
Err(e) => self.set_status(format!("Save error: {}", e)),
}
}
// Open dialog
self.open_dialog.update(ctx);
if let Some(path) = self.open_dialog.take_selected() {
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<SoundSpec>(&content) {
Ok(spec) => {
self.undo.push(&self.spec);
self.spec = spec;
self.file_path = path.display().to_string();
self.preview_dirty = true;
self.set_status(format!("Loaded from {}", path.display()));
}
Err(e) => self.set_status(format!("Parse error: {}", e)),
},
Err(e) => self.set_status(format!("Load error: {}", e)),
}
}
// Export dialog
self.export_dialog.update(ctx);
if let Some(path) = self.export_dialog.take_selected() {
let samples = render_spec(&self.spec);
match write_wav(&path, &samples, self.spec.sample_rate) {
Ok(()) => self.set_status(format!("Exported to {}", path.display())),
Err(e) => self.set_status(format!("Export error: {}", e)),
}
}
}
fn show_sfx_editor(&mut self, ui: &mut egui::Ui) {
// Sound properties
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut self.spec.name);
ui.separator();
ui.label("Duration:");
if ui
.add(
egui::Slider::new(&mut self.spec.duration, 0.01..=10.0)
.suffix("s")
.fixed_decimals(2),
)
.changed()
{
self.mark_dirty();
}
});
ui.separator();
// Channel controls
let total = self.spec.channels.len();
let mut to_remove: Option<usize> = None;
let mut to_dup: Option<usize> = None;
let mut to_move_up: Option<usize> = None;
let mut to_move_down: Option<usize> = None;
let mut any_changed = false;
for (i, channel) in self.spec.channels.iter_mut().enumerate() {
let edit = channel_panel(ui, channel, i, total, "ch");
if edit.changed {
any_changed = true;
}
if edit.deleted {
to_remove = Some(i);
}
if edit.duplicated {
to_dup = Some(i);
}
if edit.moved_up {
to_move_up = Some(i);
}
if edit.moved_down {
to_move_down = Some(i);
}
}
if any_changed {
self.mark_dirty();
}
// Process channel edits
if let Some(idx) = to_remove {
self.undo.push(&self.spec);
self.spec.channels.remove(idx);
self.mark_dirty();
}
if let Some(idx) = to_dup {
self.undo.push(&self.spec);
let dup = self.spec.channels[idx].clone();
// Adjust name if possible
self.spec.channels.insert(idx + 1, dup);
self.mark_dirty();
}
if let Some(idx) = to_move_up {
if idx > 0 {
self.undo.push(&self.spec);
self.spec.channels.swap(idx, idx - 1);
self.mark_dirty();
}
}
if let Some(idx) = to_move_down {
if idx < total - 1 {
self.undo.push(&self.spec);
self.spec.channels.swap(idx, idx + 1);
self.mark_dirty();
}
}
ui.separator();
// Waveform preview
ui.heading("Preview");
let preview_dur = self.spec.duration;
waveform_display(ui, &self.preview_samples, preview_dur, 80.0);
ui.horizontal(|ui| {
if ui.button("▶ Play").on_hover_text("Space").clicked() {
self.play_current();
}
if ui
.button("⟳ Refresh")
.on_hover_text("Force re-render")
.clicked()
{
self.update_preview();
}
if ui.button("Export WAV").on_hover_text("Ctrl+E").clicked() {
self.export_dialog.save_file();
}
});
}
fn show_sequencer(&mut self, ui: &mut egui::Ui) {
ui.heading("Sequencer (Song)");
ui.horizontal(|ui| {
ui.label("BPM:");
ui.add(egui::Slider::new(&mut self.song.bpm, 60.0..=240.0));
ui.separator();
ui.label("Rows/beat:");
ui.add(egui::Slider::new(&mut self.song.rows_per_beat, 1..=16));
});
ui.separator();
ui.horizontal(|ui| {
ui.label(format!(
"Tracks: {} · Patterns: {} · Duration: {:.1}s",
self.song.tracks.len(),
self.song.patterns.len(),
self.song.duration()
));
});
ui.separator();
// Action buttons
ui.horizontal(|ui| {
if ui.button("Format JSON").clicked() {
self.song_json = serde_json::to_string_pretty(&self.song).unwrap_or_default();
self.song_json_error = None;
}
if ui.button("Validate").clicked() {
match serde_json::from_str::<Song>(&self.song_json) {
Ok(_) => {
self.song_json_error = None;
self.set_status("JSON valid".to_string());
}
Err(e) => {
self.song_json_error = Some(format!("{}", e));
}
}
}
if ui.button("New Song").clicked() {
self.song = default_song();
self.song_json = serde_json::to_string_pretty(&self.song).unwrap_or_default();
self.song_json_error = None;
}
if ui.button("Apply JSON").clicked() {
match serde_json::from_str::<Song>(&self.song_json) {
Ok(song) => {
self.song = song;
self.song_json_error = None;
self.set_status("Song updated".to_string());
}
Err(e) => {
self.song_json_error = Some(format!("{}", e));
}
}
}
if ui.button("▶ Play Song").clicked() {
let samples = render_song(&self.song);
self.start_playback(samples, self.song.sample_rate);
}
if ui.button("Export Song WAV").clicked() {
let samples = render_song(&self.song);
let path = std::path::Path::new("song_export.wav");
match write_wav(path, &samples, self.song.sample_rate) {
Ok(()) => self.set_status(format!("Exported to {}", path.display())),
Err(e) => self.set_status(format!("Export error: {}", e)),
}
}
});
// Show error
if let Some(err) = &self.song_json_error {
ui.colored_label(
egui::Color32::from_rgb(255, 100, 100),
format!("Error: {}", err),
);
}
ui.separator();
// JSON editor
egui::ScrollArea::vertical()
.id_source("song_json")
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.song_json)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20),
);
});
}
// ── Playback ──────────────────────────────────────────
fn play_current(&mut self) {
let samples = render_spec(&self.spec);
self.preview_samples = downsample_for_display(&samples);
self.start_playback(samples, self.spec.sample_rate);
}
fn start_playback(&mut self, samples: Vec<f32>, sample_rate: u32) {
// Stop any previous playback first (prevents Drop from blocking)
self.stop_playback();
match play(&samples, sample_rate) {
Ok(handle) => {
self.playing = Some(Arc::new(Mutex::new(handle)));
self.set_status(format!("Playing '{}'...", self.spec.name));
}
Err(e) => {
self.set_status(format!("Playback error: {}", e));
}
}
}
fn stop_playback(&mut self) {
if let Some(h) = self.playing.take() {
if let Ok(mut guard) = h.lock() {
guard.stop();
}
}
}
// ── Preview ───────────────────────────────────────────
fn mark_dirty(&mut self) {
self.preview_dirty = true;
}
fn update_preview(&mut self) {
let samples = render_spec(&self.spec);
self.preview_samples = downsample_for_display(&samples);
}
// ── File operations ───────────────────────────────────
fn new_sound(&mut self) {
self.undo.push(&self.spec);
self.spec = SoundSpec {
name: "new_sound".to_string(),
duration: 0.2,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: soundgen_core::FrequencyAutomation::fixed(440.0),
envelope: None,
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
self.mark_dirty();
self.set_status("New sound".to_string());
}
// ── Undo/Redo ─────────────────────────────────────────
fn do_undo(&mut self) {
if let Some(prev) = self.undo.undo(&self.spec) {
self.spec = prev;
self.mark_dirty();
self.set_status("Undo".to_string());
}
}
fn do_redo(&mut self) {
if let Some(next) = self.undo.redo(&self.spec) {
self.spec = next;
self.mark_dirty();
self.set_status("Redo".to_string());
}
}
// ── Channels ──────────────────────────────────────────
fn add_channel(&mut self, ch_type: ChannelType) {
self.undo.push(&self.spec);
match ch_type {
ChannelType::Pulse => {
self.spec.channels.push(ChannelSpec::Pulse {
duty: 50,
frequency: soundgen_core::FrequencyAutomation::fixed(440.0),
envelope: None,
filter: None,
volume: 0.5,
pan: 0.0,
});
}
ChannelType::Triangle => {
self.spec.channels.push(ChannelSpec::Triangle {
frequency: soundgen_core::FrequencyAutomation::fixed(220.0),
envelope: None,
filter: None,
volume: 0.4,
pan: 0.0,
});
}
ChannelType::Noise => {
self.spec.channels.push(ChannelSpec::Noise {
mode: "white".to_string(),
frequency: 8000.0,
envelope: None,
filter: None,
volume: 0.3,
pan: 0.0,
});
}
}
self.mark_dirty();
}
// ── Status ────────────────────────────────────────────
fn set_status(&mut self, msg: String) {
self.status = msg;
self.status_time = Some(Instant::now());
}
}
// ── Helpers ──────────────────────────────────────────────
fn set_channel_freq(mut ch: ChannelSpec, freq: f32) -> ChannelSpec {
match &mut ch {
ChannelSpec::Pulse { frequency, .. } => {
frequency.start = freq;
frequency.end = freq;
}
ChannelSpec::Triangle { frequency, .. } => {
frequency.start = freq;
frequency.end = freq;
}
ChannelSpec::Noise { .. } => {}
}
ch
}
fn set_channel_vol(mut ch: ChannelSpec, vol: f32) -> ChannelSpec {
match &mut ch {
ChannelSpec::Pulse { volume, .. } => *volume = vol,
ChannelSpec::Triangle { volume, .. } => *volume = vol,
ChannelSpec::Noise { volume, .. } => *volume = vol,
}
ch
}
fn downsample_for_display(samples: &[f32]) -> Vec<f32> {
// Interleaved stereo → mono
let mono: Vec<f32> = samples.iter().step_by(2).cloned().collect();
let target_len = 1500usize;
if mono.len() > target_len {
let step = mono.len() / target_len;
mono.iter()
.step_by(step)
.take(target_len)
.cloned()
.collect()
} else {
mono
}
}
+480
View File
@@ -0,0 +1,480 @@
//! Channel panel — controls for duty cycle, frequency, envelope, filter, volume, pan.
//!
//! Features:
//! - Collapsible channels with color-coded headers
//! - Compact ADSR with mini visual shape
//! - Filter controls (type, cutoff, Q, sweep)
//! - Move up/down, duplicate, delete buttons
use egui::{Color32, Ui};
use soundgen_core::{FrequencyAutomation, SweepCurve};
use soundgen_fmt::{ChannelSpec, CutoffAutomation, EnvelopeSpec, FilterKind, FilterSpec};
/// Channel type color for headers.
pub fn channel_type_color(channel: &ChannelSpec) -> Color32 {
match channel {
ChannelSpec::Pulse { .. } => Color32::from_rgb(255, 200, 80), // amber
ChannelSpec::Triangle { .. } => Color32::from_rgb(100, 255, 150), // green
ChannelSpec::Noise { .. } => Color32::from_rgb(180, 180, 190), // gray
}
}
/// Channel type name.
pub fn channel_type_name(channel: &ChannelSpec) -> &'static str {
match channel {
ChannelSpec::Pulse { .. } => "Pulse",
ChannelSpec::Triangle { .. } => "Triangle",
ChannelSpec::Noise { .. } => "Noise",
}
}
/// Draw controls for a channel spec inside a collapsing header.
/// Returns true if the spec was modified.
pub fn channel_panel(
ui: &mut Ui,
channel: &mut ChannelSpec,
index: usize,
total: usize,
id_source: &str,
) -> ChannelEdit {
let mut edit = ChannelEdit::default();
let color = channel_type_color(channel);
let name = channel_type_name(channel);
// Header with color bar + buttons
ui.horizontal(|ui| {
// Color indicator
let (bar_rect, _) =
ui.allocate_exact_size(egui::Vec2::new(4.0, 20.0), egui::Sense::hover());
ui.painter().rect_filled(bar_rect, 2.0, color);
// Collapsing header
let header_text = format!("Ch {} · {}", index + 1, name);
let id = format!("{}_{}", id_source, index);
egui::CollapsingHeader::new(header_text)
.id_source(id)
.default_open(true)
.show(ui, |ui| {
edit.changed = channel_controls(ui, channel, index);
});
// Move/dup/delete buttons
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("×").on_hover_text("Delete channel").clicked() {
edit.deleted = true;
}
if ui.button("⧉").on_hover_text("Duplicate channel").clicked() {
edit.duplicated = true;
}
ui.add_enabled_ui(index > 0, |ui| {
if ui.button("▲").on_hover_text("Move up").clicked() {
edit.moved_up = true;
}
});
ui.add_enabled_ui(index < total - 1, |ui| {
if ui.button("▼").on_hover_text("Move down").clicked() {
edit.moved_down = true;
}
});
});
});
edit
}
/// Actions from channel editing.
#[derive(Default)]
pub struct ChannelEdit {
pub changed: bool,
pub deleted: bool,
pub duplicated: bool,
pub moved_up: bool,
pub moved_down: bool,
}
fn channel_controls(ui: &mut Ui, channel: &mut ChannelSpec, _index: usize) -> bool {
let mut changed = false;
match channel {
ChannelSpec::Pulse {
duty,
frequency,
envelope,
filter,
volume,
pan,
} => {
// Duty cycle dropdown
ui.horizontal(|ui| {
ui.label("Duty:");
let mut duty_val = *duty;
let resp = egui::ComboBox::from_id_source("duty")
.selected_text(format!("{}%", duty_val))
.show_ui(ui, |ui| {
ui.selectable_value(&mut duty_val, 12, "12.5%");
ui.selectable_value(&mut duty_val, 25, "25%");
ui.selectable_value(&mut duty_val, 50, "50%");
ui.selectable_value(&mut duty_val, 75, "75%");
});
if resp.response.changed() {
*duty = duty_val;
changed = true;
}
});
changed |= freq_controls(ui, frequency);
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vol_pan_controls(ui, volume, pan);
}
ChannelSpec::Triangle {
frequency,
envelope,
filter,
volume,
pan,
} => {
changed |= freq_controls(ui, frequency);
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vol_pan_controls(ui, volume, pan);
}
ChannelSpec::Noise {
mode,
frequency,
envelope,
filter,
volume,
pan,
} => {
ui.horizontal(|ui| {
ui.label("Mode:");
let mut is_white = *mode == "white";
if ui.radio_value(&mut is_white, true, "White").changed() {
*mode = "white".to_string();
changed = true;
}
if ui.radio_value(&mut is_white, false, "Periodic").changed() {
*mode = "periodic".to_string();
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Clock:");
if ui
.add(
egui::Slider::new(frequency, 100.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
});
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vol_pan_controls(ui, volume, pan);
}
}
changed
}
fn freq_controls(ui: &mut Ui, freq: &mut FrequencyAutomation) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Freq:");
if ui
.add(
egui::Slider::new(&mut freq.start, 20.0..=8000.0)
.logarithmic(true)
.suffix(" Hz")
.text("start"),
)
.on_hover_text("Starting frequency")
.changed()
{
changed = true;
}
if ui
.add(
egui::Slider::new(&mut freq.end, 20.0..=8000.0)
.logarithmic(true)
.suffix(" Hz")
.text("end"),
)
.on_hover_text("Ending frequency (sweep target)")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Curve:");
if ui
.radio_value(&mut freq.curve, SweepCurve::Linear, "Linear")
.changed()
{
changed = true;
}
if ui
.radio_value(&mut freq.curve, SweepCurve::Exponential, "Exponential")
.changed()
{
changed = true;
}
});
changed
}
fn envelope_controls(ui: &mut Ui, envelope: &mut Option<EnvelopeSpec>) -> bool {
let mut changed = false;
if envelope.is_none() {
*envelope = Some(EnvelopeSpec::default());
}
let env = envelope.as_mut().unwrap();
// ADSR visual + sliders in compact layout
ui.horizontal(|ui| {
// Mini ADSR visual
crate::waveform::adsr_visual(ui, env.attack, env.decay, env.sustain, env.release, 1.0);
// Compact sliders in a grid
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label("A:");
if ui
.add(
egui::Slider::new(&mut env.attack, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Attack time (seconds)")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("D:");
if ui
.add(
egui::Slider::new(&mut env.decay, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Decay time (seconds)")
.changed()
{
changed = true;
}
});
});
});
ui.horizontal(|ui| {
ui.label("S:");
if ui
.add(
egui::Slider::new(&mut env.sustain, 0.0..=1.0)
.clamp_to_range(true)
.fixed_decimals(2),
)
.on_hover_text("Sustain level (0-1)")
.changed()
{
changed = true;
}
ui.label("R:");
if ui
.add(
egui::Slider::new(&mut env.release, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Release time (seconds)")
.changed()
{
changed = true;
}
});
changed
}
fn filter_controls(ui: &mut Ui, filter: &mut Option<FilterSpec>) -> bool {
let mut changed = false;
let has_filter = filter.is_some();
ui.horizontal(|ui| {
let mut enable = has_filter;
if ui
.checkbox(&mut enable, "Filter")
.on_hover_text("Enable biquad filter")
.changed()
{
if enable && filter.is_none() {
*filter = Some(FilterSpec {
kind: FilterKind::Lowpass,
cutoff: 2000.0,
cutoff_sweep: None,
q: 0.707,
});
changed = true;
} else if !enable && filter.is_some() {
*filter = None;
changed = true;
}
}
});
if let Some(filt) = filter {
ui.horizontal(|ui| {
ui.label("Type:");
let mut kind = filt.kind;
let resp = egui::ComboBox::from_id_source("filter_type")
.selected_text(match kind {
FilterKind::Lowpass => "Lowpass",
FilterKind::Highpass => "Highpass",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut kind, FilterKind::Lowpass, "Lowpass");
ui.selectable_value(&mut kind, FilterKind::Highpass, "Highpass");
});
if resp.response.changed() {
filt.kind = kind;
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Cutoff:");
if ui
.add(
egui::Slider::new(&mut filt.cutoff, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.on_hover_text("Filter cutoff frequency")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Q:");
if ui
.add(
egui::Slider::new(&mut filt.q, 0.1..=10.0)
.fixed_decimals(2)
.text("resonance"),
)
.on_hover_text("Filter resonance (higher = sharper)")
.changed()
{
changed = true;
}
});
// Cutoff sweep
ui.horizontal(|ui| {
let mut has_sweep = filt.cutoff_sweep.is_some();
if ui
.checkbox(&mut has_sweep, "Cutoff sweep")
.on_hover_text("Automate cutoff over time")
.changed()
{
if has_sweep {
filt.cutoff_sweep = Some(CutoffAutomation {
start: filt.cutoff,
end: filt.cutoff * 0.1,
curve: SweepCurve::Exponential,
});
changed = true;
} else {
filt.cutoff_sweep = None;
changed = true;
}
}
});
if let Some(sweep) = &mut filt.cutoff_sweep {
ui.horizontal(|ui| {
ui.label(" Start:");
if ui
.add(
egui::Slider::new(&mut sweep.start, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
ui.label("End:");
if ui
.add(
egui::Slider::new(&mut sweep.end, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label(" Curve:");
if ui
.radio_value(&mut sweep.curve, SweepCurve::Linear, "Linear")
.changed()
{
changed = true;
}
if ui
.radio_value(&mut sweep.curve, SweepCurve::Exponential, "Exponential")
.changed()
{
changed = true;
}
});
}
}
changed
}
fn vol_pan_controls(ui: &mut Ui, volume: &mut f32, pan: &mut f32) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Vol:");
if ui
.add(egui::Slider::new(volume, 0.0..=1.0).fixed_decimals(2))
.on_hover_text("Channel volume (0-1)")
.changed()
{
changed = true;
}
ui.label("Pan:");
if ui
.add(
egui::Slider::new(pan, -1.0..=1.0)
.fixed_decimals(2)
.text("L/R"),
)
.on_hover_text("Pan (-1=left, 0=center, 1=right)")
.changed()
{
changed = true;
}
});
changed
}
+328
View File
@@ -0,0 +1,328 @@
//! Virtual piano keyboard widget for egui.
//!
//! Features:
//! - Responsive key sizing (fits available width)
//! - Octave shift (Z/X keys or ◀ ▶ buttons)
//! - QWERTY letter labels on keys
//! - Velocity slider
//! - Mouse + keyboard input
use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2};
/// Convert MIDI note number to frequency.
pub fn midi_to_freq(midi: u8) -> f32 {
440.0 * 2.0f32.powf((midi as f32 - 69.0) / 12.0)
}
/// Is this MIDI note a black key?
fn is_black_key(midi: u8) -> bool {
let n = midi % 12;
n == 1 || n == 3 || n == 6 || n == 8 || n == 10
}
/// QWERTY-to-semitone mapping (relative to start_note).
const QWERTY_MAP: &[(egui::Key, &str, u8)] = &[
(egui::Key::A, "A", 0),
(egui::Key::W, "W", 1),
(egui::Key::S, "S", 2),
(egui::Key::E, "E", 3),
(egui::Key::D, "D", 4),
(egui::Key::F, "F", 5),
(egui::Key::T, "T", 6),
(egui::Key::G, "G", 7),
(egui::Key::Y, "Y", 8),
(egui::Key::H, "H", 9),
(egui::Key::U, "U", 10),
(egui::Key::J, "J", 11),
(egui::Key::K, "K", 12),
(egui::Key::O, "O", 13),
(egui::Key::L, "L", 14),
];
/// Piano keyboard widget.
pub struct Keyboard {
/// Lowest MIDI note (changes with octave shift)
pub start_note: u8,
/// Number of octaves to display
pub octaves: usize,
/// Currently pressed notes (MIDI numbers)
pub pressed: std::collections::HashSet<u8>,
/// Velocity (0-127)
pub velocity: u8,
/// Responsive key width (computed each frame)
key_w: f32,
/// White key height
key_h: f32,
/// Black key width
black_key_w: f32,
/// Black key height
black_key_h: f32,
}
impl Keyboard {
pub fn new(start_note: u8, octaves: usize) -> Self {
Self {
start_note,
octaves,
pressed: std::collections::HashSet::new(),
velocity: 100,
key_w: 28.0,
key_h: 90.0,
black_key_w: 18.0,
black_key_h: 56.0,
}
}
/// Shift octave down.
pub fn octave_down(&mut self) {
if self.start_note >= 12 {
self.start_note -= 12;
}
}
/// Shift octave up.
pub fn octave_up(&mut self) {
if self.start_note + 12 * self.octaves as u8 <= 120 {
self.start_note += 12;
}
}
/// Get the current octave range label (e.g., "C4–C6").
pub fn range_label(&self) -> String {
let bottom = self.start_note;
let top = self.start_note + 12 * self.octaves as u8;
format!("{}–{}", note_name(bottom), note_name(top))
}
/// Draw the keyboard and handle input.
/// Returns (newly_pressed, newly_released, velocity).
pub fn show(&mut self, ui: &mut Ui) -> (Vec<u8>, Vec<u8>, u8) {
let mut new_presses = Vec::new();
let mut new_releases = Vec::new();
// Octave shift controls + velocity
ui.horizontal(|ui| {
if ui.button("◀").on_hover_text("Octave down (Z)").clicked() {
self.octave_down();
}
ui.label(
egui::RichText::new(self.range_label())
.strong()
.color(Color32::from_rgb(140, 180, 255)),
);
if ui.button("▶").on_hover_text("Octave up (X)").clicked() {
self.octave_up();
}
ui.separator();
ui.label("Vel:");
ui.add(
egui::Slider::new(&mut self.velocity, 1..=127)
.clamp_to_range(true)
.text(""),
)
.on_hover_text("Velocity (loudness)");
});
// Compute responsive key width
let num_white_keys = self.octaves * 7 + 1;
let avail_w = ui.available_width().min(num_white_keys as f32 * 40.0);
self.key_w = (avail_w / num_white_keys as f32).clamp(16.0, 40.0);
self.black_key_w = self.key_w * 0.6;
self.key_h = 90.0;
self.black_key_h = 56.0;
let total_w = num_white_keys as f32 * self.key_w;
let (rect, _) =
ui.allocate_exact_size(Vec2::new(total_w, self.key_h), Sense::click_and_drag());
let painter = ui.painter_at(rect);
// Collect all notes to draw
let mut notes: Vec<u8> = Vec::new();
let mut white_count = 0u32;
let mut midi = self.start_note;
for _ in 0..(num_white_keys * 2) {
if midi > 127 {
break;
}
notes.push(midi);
if !is_black_key(midi) {
white_count += 1;
}
if white_count >= num_white_keys as u32 {
break;
}
midi += 1;
}
// Draw white keys
let mut white_x = rect.left();
let mut white_key_positions: Vec<(u8, Rect, &str)> = Vec::new();
for &note in &notes {
if is_black_key(note) {
continue;
}
let key_rect = Rect::from_min_size(
Pos2::new(white_x, rect.top()),
Vec2::new(self.key_w, self.key_h),
);
let is_pressed = self.pressed.contains(&note);
let color = if is_pressed {
Color32::from_rgb(100, 160, 255)
} else {
Color32::from_rgb(235, 235, 240)
};
painter.rect_filled(key_rect, 3.0, color);
painter.rect_stroke(key_rect, 3.0, (1.0, Color32::from_rgb(60, 60, 70)));
// C note labels
if note % 12 == 0 {
painter.text(
Pos2::new(white_x + self.key_w * 0.5, rect.bottom() - 14.0),
egui::Align2::CENTER_CENTER,
note_name(note),
egui::FontId::proportional(11.0),
Color32::from_rgb(100, 100, 120),
);
}
// QWERTY label
if let Some(label) = qwerty_label_for_note(note, self.start_note) {
painter.text(
Pos2::new(white_x + self.key_w * 0.5, rect.bottom() - 28.0),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(9.0),
Color32::from_rgba_premultiplied(120, 120, 140, 100),
);
}
white_key_positions.push((note, key_rect, ""));
white_x += self.key_w;
}
// Draw black keys (on top)
let mut white_x = rect.left();
let mut black_key_positions: Vec<(u8, Rect)> = Vec::new();
for &note in &notes {
if is_black_key(note) {
let black_x = white_x - self.black_key_w * 0.5;
let key_rect = Rect::from_min_size(
Pos2::new(black_x, rect.top()),
Vec2::new(self.black_key_w, self.black_key_h),
);
let is_pressed = self.pressed.contains(&note);
let color = if is_pressed {
Color32::from_rgb(70, 100, 200)
} else {
Color32::from_rgb(35, 35, 42)
};
painter.rect_filled(key_rect, 2.0, color);
painter.rect_stroke(key_rect, 2.0, (1.0, Color32::from_rgb(80, 80, 90)));
// QWERTY label on black key
if let Some(label) = qwerty_label_for_note(note, self.start_note) {
painter.text(
Pos2::new(
black_x + self.black_key_w * 0.5,
rect.top() + self.black_key_h - 14.0,
),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(8.0),
Color32::from_rgba_premultiplied(180, 180, 200, 120),
);
}
black_key_positions.push((note, key_rect));
} else {
white_x += self.key_w;
}
}
// Handle mouse input
let mouse_pos = ui.input(|i| i.pointer.hover_pos());
let mouse_down = ui.input(|i| i.pointer.primary_down());
let mouse_released = ui.input(|i| i.pointer.primary_released());
if let Some(pos) = mouse_pos {
if mouse_down || mouse_released {
// Check black keys first (on top)
let mut hit_note: Option<u8> = None;
for (note, kr) in &black_key_positions {
if kr.contains(pos) {
hit_note = Some(*note);
break;
}
}
if hit_note.is_none() {
for (note, kr, _) in &white_key_positions {
if kr.contains(pos) {
hit_note = Some(*note);
break;
}
}
}
if let Some(note) = hit_note {
if mouse_down && !self.pressed.contains(&note) {
self.pressed.insert(note);
new_presses.push(note);
}
}
if mouse_released {
let released: Vec<u8> = self.pressed.iter().copied().collect();
for n in released {
self.pressed.remove(&n);
new_releases.push(n);
}
}
}
}
// Handle QWERTY keyboard input
for (key, _label, semitone) in QWERTY_MAP {
let midi = self.start_note + semitone;
let pressed_now = ui.input(|i| i.key_down(*key));
let was_pressed = self.pressed.contains(&midi);
if pressed_now && !was_pressed {
self.pressed.insert(midi);
new_presses.push(midi);
} else if !pressed_now && was_pressed {
self.pressed.remove(&midi);
new_releases.push(midi);
}
}
// Handle octave shift keys (Z / X)
if ui.input(|i| i.key_pressed(egui::Key::Z)) {
self.octave_down();
}
if ui.input(|i| i.key_pressed(egui::Key::X)) {
self.octave_up();
}
(new_presses, new_releases, self.velocity)
}
}
/// Get the QWERTY letter label for a given MIDI note (if mapped).
fn qwerty_label_for_note(note: u8, start_note: u8) -> Option<&'static str> {
let semitone = note - start_note;
QWERTY_MAP
.iter()
.find(|(_, _, s)| *s == semitone)
.map(|(_, label, _)| *label)
}
/// Get note name (e.g., "C4", "A#3").
fn note_name(midi: u8) -> String {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let octave = midi / 12 - 1;
let note = midi % 12;
format!("{}{}", NAMES[note as usize], octave)
}
+42
View File
@@ -0,0 +1,42 @@
//! Soundgen GUI — egui-based 8-bit sound editor.
//!
//! Features:
//! - Virtual piano keyboard (mouse + QWERTY, octave shift, velocity)
//! - SFX editor with collapsible channels, ADSR visual, filter controls
//! - Preset browser with click-to-play, color-coded categories
//! - Sequencer with JSON editor
//! - Undo/redo, hotkeys, file dialogs
//! - Save/load projects (JSON), WAV export
mod app;
mod channel_panel;
mod keyboard;
mod preset_browser;
mod waveform;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1280.0, 820.0])
.with_title("Soundgen — 8-bit Sound Synthesizer"),
..Default::default()
};
eframe::run_native(
"Soundgen",
options,
Box::new(|cc| {
setup_custom_theme(&cc.egui_ctx);
Ok(Box::new(app::SoundgenApp::default()))
}),
)
}
fn setup_custom_theme(ctx: &egui::Context) {
let mut theme = egui::Visuals::dark();
theme.panel_fill = egui::Color32::from_rgb(24, 24, 28);
theme.window_fill = egui::Color32::from_rgb(32, 32, 38);
theme.extreme_bg_color = egui::Color32::from_rgb(16, 16, 20);
theme.faint_bg_color = egui::Color32::from_rgb(40, 40, 48);
ctx.set_visuals(theme);
}
+136
View File
@@ -0,0 +1,136 @@
//! Preset browser — list, filter, and play presets with color-coded categories.
use egui::{Color32, RichText, Ui};
use soundgen_fmt::{PresetCategory, PresetRegistry};
pub struct PresetBrowser {
pub selected: Option<String>,
pub filter: String,
}
impl PresetBrowser {
pub fn new() -> Self {
Self {
selected: None,
filter: String::new(),
}
}
/// Draw the browser. Returns the name of a preset to play (double-click or play button).
pub fn show(&mut self, ui: &mut Ui, registry: &PresetRegistry) -> Option<String> {
let mut to_play = None;
ui.heading("Presets");
ui.horizontal(|ui| {
ui.text_edit_singleline(&mut self.filter)
.on_hover_text("Filter by name...");
if !self.filter.is_empty() {
if ui.button("✕").clicked() {
self.filter.clear();
}
}
});
egui::ScrollArea::vertical().show(ui, |ui| {
let filter_lower = self.filter.to_lowercase();
for category in [
PresetCategory::Sfx,
PresetCategory::Ui,
PresetCategory::Ambient,
] {
let presets: Vec<_> = registry
.list(Some(category))
.into_iter()
.filter(|p| p.name.to_lowercase().contains(&filter_lower))
.collect();
if presets.is_empty() {
continue;
}
let cat_color = category_color(category);
let header = format!("{} ({})", category.as_str().to_uppercase(), presets.len());
ui.collapsing(header, |ui| {
for entry in &presets {
let is_selected = self.selected.as_deref() == Some(entry.name.as_str());
let bg = if is_selected {
Color32::from_rgb(50, 70, 110)
} else {
Color32::from_rgb(35, 35, 42)
};
let frame = egui::Frame::group(ui.style())
.fill(bg)
.stroke(egui::Stroke::new(
if is_selected { 2.0 } else { 0.5 },
if is_selected {
cat_color
} else {
Color32::from_gray(60)
},
))
.inner_margin(egui::Margin::symmetric(6.0, 4.0));
let response = frame.show(ui, |ui| {
ui.horizontal(|ui| {
// Category color dot
let (dot_rect, _) = ui.allocate_exact_size(
egui::Vec2::new(8.0, 8.0),
egui::Sense::hover(),
);
ui.painter()
.circle_filled(dot_rect.center(), 4.0, cat_color);
// Name
ui.label(RichText::new(&entry.name).strong());
// Info
ui.label(
RichText::new(format!(
"{:.2}s · {}ch",
entry.spec.duration,
entry.spec.channels.len()
))
.small()
.weak(),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("▶").on_hover_text("Play").clicked() {
to_play = Some(entry.name.clone());
self.selected = Some(entry.name.clone());
}
},
);
});
});
let resp = response.response.interact(egui::Sense::click());
if resp.clicked() {
self.selected = Some(entry.name.clone());
}
if resp.double_clicked() {
to_play = Some(entry.name.clone());
self.selected = Some(entry.name.clone());
}
}
});
}
});
to_play
}
}
fn category_color(cat: PresetCategory) -> Color32 {
match cat {
PresetCategory::Sfx => Color32::from_rgb(255, 120, 80),
PresetCategory::Ui => Color32::from_rgb(120, 200, 255),
PresetCategory::Ambient => Color32::from_rgb(150, 255, 150),
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Waveform display widget with time grid, channel colors, and RMS meter.
use egui::{Color32, FontId, Pos2, Rect, Ui, Vec2};
/// Channel type colors for waveform display.
#[allow(dead_code)]
pub fn channel_color(index: usize) -> Color32 {
match index % 6 {
0 => Color32::from_rgb(100, 200, 255),
1 => Color32::from_rgb(255, 200, 100),
2 => Color32::from_rgb(150, 255, 150),
3 => Color32::from_rgb(255, 150, 150),
4 => Color32::from_rgb(200, 150, 255),
_ => Color32::from_rgb(255, 255, 150),
}
}
/// Draw a waveform preview with time grid and labels.
///
/// - `samples`: mono samples (downsampled for display)
/// - `duration`: total duration in seconds
/// - `height`: widget height in pixels
pub fn waveform_display(ui: &mut Ui, samples: &[f32], duration: f32, height: f32) {
let avail_w = ui.available_width();
let size = Vec2::new(avail_w, height);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let painter = ui.painter_at(rect);
// Inset: leave room for labels at bottom and RMS meter at right
let label_h = 14.0;
let meter_w = 8.0;
let pad = 4.0;
let wave_rect = Rect::from_min_max(
Pos2::new(rect.left() + pad, rect.top() + pad),
Pos2::new(rect.right() - meter_w - pad * 2.0, rect.bottom() - label_h),
);
// Background
painter.rect_filled(rect, 4.0, Color32::from_rgb(18, 18, 24));
if samples.len() < 2 {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"No preview",
FontId::proportional(14.0),
Color32::from_rgb(80, 80, 100),
);
return;
}
// Time grid
let grid_interval = if duration < 0.5 {
0.05
} else if duration < 2.0 {
0.1
} else if duration < 10.0 {
0.5
} else {
1.0
};
let n_lines = (duration / grid_interval) as usize;
for i in 0..=n_lines {
let t = i as f32 * grid_interval;
let x = wave_rect.left() + (t / duration) * wave_rect.width();
let alpha = if i == 0 || i == n_lines { 50 } else { 25 };
painter.line_segment(
[
Pos2::new(x, wave_rect.top()),
Pos2::new(x, wave_rect.bottom()),
],
(1.0, Color32::from_rgba_premultiplied(80, 80, 100, alpha)),
);
if i > 0 && i < n_lines {
painter.text(
Pos2::new(x, wave_rect.bottom() + 2.0),
egui::Align2::CENTER_TOP,
if grid_interval < 1.0 {
format!("{:.0}ms", t * 1000.0)
} else {
format!("{:.1}s", t)
},
FontId::proportional(8.0),
Color32::from_rgb(70, 70, 90),
);
}
}
// Center line
let mid_y = wave_rect.center().y;
painter.line_segment(
[
Pos2::new(wave_rect.left(), mid_y),
Pos2::new(wave_rect.right(), mid_y),
],
(1.0, Color32::from_rgba_premultiplied(60, 60, 80, 60)),
);
// Waveform line
let amp = wave_rect.height() * 0.45;
let color = Color32::from_rgb(100, 200, 255);
let fill_color = Color32::from_rgba_premultiplied(100, 200, 255, 40);
let points: Vec<Pos2> = samples
.iter()
.enumerate()
.map(|(i, &s)| {
let x = wave_rect.left() + (i as f32 / samples.len() as f32) * wave_rect.width();
let y = mid_y - s.clamp(-1.0, 1.0) * amp;
Pos2::new(x, y)
})
.collect();
// Fill under curve: draw vertical lines from each point to mid_y
for p in &points {
painter.line_segment([Pos2::new(p.x, mid_y), *p], (1.0, fill_color));
}
// Waveform line on top
painter.add(egui::Shape::line(points, (1.5, color)));
// RMS meter (right side, separate from waveform area)
let rms = (samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt();
let rms_db = 20.0 * rms.max(1e-6).log10();
let meter_rect = Rect::from_min_size(
Pos2::new(rect.right() - meter_w - pad, wave_rect.top()),
Vec2::new(meter_w, wave_rect.height()),
);
painter.rect_filled(meter_rect, 2.0, Color32::from_rgb(30, 30, 40));
let level = ((rms_db + 60.0) / 60.0).clamp(0.0, 1.0);
let level_h = level * meter_rect.height();
let level_color = if level > 0.85 {
Color32::from_rgb(255, 80, 80)
} else if level > 0.6 {
Color32::from_rgb(255, 200, 80)
} else {
Color32::from_rgb(80, 200, 120)
};
painter.rect_filled(
Rect::from_min_size(
Pos2::new(meter_rect.left(), meter_rect.bottom() - level_h),
Vec2::new(meter_w, level_h),
),
2.0,
level_color,
);
// Info text (top-left, inside wave rect)
painter.text(
Pos2::new(wave_rect.left() + 4.0, wave_rect.top() + 2.0),
egui::Align2::LEFT_TOP,
format!("{:.2}s · RMS {:.1} dB", duration, rms_db),
FontId::proportional(10.0),
Color32::from_rgb(120, 140, 160),
);
}
/// Draw a mini ADSR envelope shape.
pub fn adsr_visual(
ui: &mut Ui,
attack: f32,
decay: f32,
sustain: f32,
release: f32,
_total_dur: f32,
) {
let width = 120.0;
let height = 50.0;
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover());
let painter = ui.painter_at(rect);
// Background
painter.rect_filled(rect, 3.0, Color32::from_rgb(20, 20, 28));
let pad = 3.0;
let inner = Rect::from_min_max(
Pos2::new(rect.left() + pad, rect.top() + pad),
Pos2::new(rect.right() - pad, rect.bottom() - pad),
);
// Calculate segment boundaries
let total = attack + decay + 0.3 + release;
let total = total.max(0.01);
let atk_x = inner.left() + (attack / total) * inner.width();
let dec_x = atk_x + (decay / total) * inner.width();
let sus_end = inner.right() - (release / total) * inner.width();
let top = inner.top();
let bottom = inner.bottom();
let sustain_y = bottom - sustain * (bottom - top);
// Envelope points
let points = vec![
Pos2::new(inner.left(), bottom),
Pos2::new(atk_x, top),
Pos2::new(dec_x, sustain_y),
Pos2::new(sus_end, sustain_y),
Pos2::new(inner.right(), bottom),
];
// Fill: draw as a series of triangles fan from bottom-left
let fill_color = Color32::from_rgba_premultiplied(120, 180, 255, 30);
for w in points.windows(2) {
let tri = vec![Pos2::new(inner.left(), bottom), w[0], w[1]];
painter.add(egui::Shape::convex_polygon(
tri,
fill_color,
(0.0, Color32::TRANSPARENT),
));
}
// Envelope line
let color = Color32::from_rgb(120, 180, 255);
painter.add(egui::Shape::line(points, (2.0, color)));
// Segment labels
let label_color = Color32::from_rgb(100, 120, 140);
let font = FontId::proportional(8.0);
painter.text(
Pos2::new((inner.left() + atk_x) * 0.5, bottom + 1.0),
egui::Align2::CENTER_TOP,
"A",
font.clone(),
label_color,
);
painter.text(
Pos2::new((atk_x + dec_x) * 0.5, bottom + 1.0),
egui::Align2::CENTER_TOP,
"D",
font.clone(),
label_color,
);
painter.text(
Pos2::new((dec_x + sus_end) * 0.5, bottom + 1.0),
egui::Align2::CENTER_TOP,
"S",
font.clone(),
label_color,
);
painter.text(
Pos2::new((sus_end + inner.right()) * 0.5, bottom + 1.0),
egui::Align2::CENTER_TOP,
"R",
font,
label_color,
);
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "soundgen-io"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = []
realtime = ["dep:cpal"]
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
hound.workspace = true
cpal = { workspace = true, optional = true }
+14
View File
@@ -0,0 +1,14 @@
//! WAV I/O using `hound`.
//! Audio playback via subprocess (paplay/aplay) or cpal (realtime feature).
pub mod player;
pub mod wav;
pub use player::{play, play_spec, PlaybackHandle};
pub use wav::{write_wav, write_wav_bits, write_wav_mono, write_wav_mono_bits};
#[cfg(feature = "realtime")]
pub mod realtime;
#[cfg(feature = "realtime")]
pub use realtime::AudioPlayer;
+104
View File
@@ -0,0 +1,104 @@
//! Audio playback — uses cpal if realtime feature is enabled,
//! otherwise falls back to subprocess (paplay/aplay).
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
static FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Play interleaved stereo samples. Returns a handle that can be used to stop.
pub fn play(samples: &[f32], sample_rate: u32) -> Result<PlaybackHandle, String> {
// Write to a unique temp WAV (unique per call, not per process)
let id = FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = std::env::temp_dir().join(format!(
"soundgen_preview_{}_{}.wav",
std::process::id(),
id
));
crate::write_wav(&tmp, samples, sample_rate)?;
// Try paplay, then aplay, then pw-play
let child = Command::new("paplay")
.arg(&tmp)
.spawn()
.or_else(|_| Command::new("aplay").arg("-q").arg(&tmp).spawn())
.or_else(|_| Command::new("pw-play").arg(&tmp).spawn())
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!(
"no audio player available (tried paplay, aplay, pw-play): {}",
e
)
})?;
Ok(PlaybackHandle {
child: Some(child),
tmp_file: Some(tmp),
stopped: Arc::new(AtomicBool::new(false)),
})
}
/// Handle to a playing sound. Drop to let it finish naturally.
pub struct PlaybackHandle {
child: Option<std::process::Child>,
tmp_file: Option<PathBuf>,
stopped: Arc<AtomicBool>,
}
impl PlaybackHandle {
/// Check if playback is still running.
pub fn is_playing(&mut self) -> bool {
if self.stopped.load(Ordering::Relaxed) {
return false;
}
match &mut self.child {
Some(child) => match child.try_wait() {
Ok(Some(_)) => false,
Ok(None) => true,
Err(_) => false,
},
None => false,
}
}
/// Stop playback — kills the subprocess and cleans up.
pub fn stop(&mut self) {
if self.stopped.swap(true, Ordering::Relaxed) {
return; // already stopped
}
if let Some(child) = &mut self.child {
let _ = child.kill();
}
// Clean up temp file immediately on explicit stop
if let Some(path) = &self.tmp_file {
let _ = std::fs::remove_file(path);
}
self.tmp_file = None;
}
}
impl Drop for PlaybackHandle {
fn drop(&mut self) {
// On drop: if not explicitly stopped, let the subprocess finish
// naturally and clean up the temp file after.
if !self.stopped.load(Ordering::Relaxed) {
// Wait briefly for the child to finish, then clean up
if let Some(child) = &mut self.child {
// Give it up to 5 seconds to finish
let _ = child.wait();
}
if let Some(path) = &self.tmp_file {
let _ = std::fs::remove_file(path);
}
}
// If explicitly stopped, cleanup already happened in stop()
}
}
/// Play a SoundSpec by rendering it and playing the result.
pub fn play_spec(spec: &soundgen_fmt::SoundSpec) -> Result<PlaybackHandle, String> {
let samples = soundgen_fmt::render_spec(spec);
play(&samples, spec.sample_rate)
}
+206
View File
@@ -0,0 +1,206 @@
//! Realtime audio playback via cpal.
//!
//! Pre-renders sound to a buffer and plays it through the default audio device.
//! Suitable for SFX preview and short clips.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
/// A shared playback buffer with atomic read position.
/// The audio callback reads from this; the main thread fills it.
struct PlaybackState {
buffer: Vec<f32>,
position: AtomicUsize,
channels: u16,
}
/// Realtime audio player. Drop to stop playback.
pub struct AudioPlayer {
_stream: cpal::Stream,
state: Arc<PlaybackState>,
}
impl AudioPlayer {
/// Play interleaved stereo samples through the default audio device.
pub fn play(samples: &[f32], sample_rate: u32) -> Result<Self, String> {
Self::play_with_channels(samples, sample_rate, 2)
}
/// Play interleaved samples with the given channel count.
pub fn play_with_channels(
samples: &[f32],
sample_rate: u32,
channels: u16,
) -> Result<Self, String> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or("no audio output device available")?;
let supported_config = device
.supported_output_configs()
.map_err(|e| format!("enumerate configs: {}", e))?
.find(|c| c.channels() == channels)
.or_else(|| {
device
.supported_output_configs()
.ok()
.and_then(|mut c| c.next())
})
.ok_or("no supported output config")?;
let config = supported_config
.with_sample_rate(cpal::SampleRate(sample_rate))
.config();
let state = Arc::new(PlaybackState {
buffer: samples.to_vec(),
position: AtomicUsize::new(0),
channels: config.channels,
});
let state_clone = Arc::clone(&state);
let data_type = config.sample_format;
let stream = match data_type {
cpal::SampleFormat::F32 => {
let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
read_samples(&state_clone, data);
};
device
.build_output_stream(
&config,
data_fn,
|err| eprintln!("audio error: {}", err),
None,
)
.map_err(|e| format!("build stream: {}", e))?
}
cpal::SampleFormat::I16 => {
let data_fn = move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
read_samples_i16(&state_clone, data);
};
device
.build_output_stream(
&config,
data_fn,
|err| eprintln!("audio error: {}", err),
None,
)
.map_err(|e| format!("build stream: {}", e))?
}
cpal::SampleFormat::U16 => {
let data_fn = move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
read_samples_u16(&state_clone, data);
};
device
.build_output_stream(
&config,
data_fn,
|err| eprintln!("audio error: {}", err),
None,
)
.map_err(|e| format!("build stream: {}", e))?
}
_ => return Err("unsupported sample format".to_string()),
};
stream.play().map_err(|e| format!("start stream: {}", e))?;
Ok(Self {
_stream: stream,
state,
})
}
/// Check if playback has finished.
pub fn is_finished(&self) -> bool {
self.state.position.load(Ordering::Relaxed) >= self.state.buffer.len()
}
/// Stop playback.
pub fn stop(&self) {
self.state
.position
.store(self.state.buffer.len(), Ordering::Relaxed);
}
}
fn read_samples(state: &PlaybackState, output: &mut [f32]) {
let mut pos = state.position.load(Ordering::Relaxed);
for frame in output.chunks_mut(state.channels as usize) {
if pos >= state.buffer.len() {
for s in frame.iter_mut() {
*s = 0.0;
}
continue;
}
let src_channels = state.channels as usize;
for (i, s) in frame.iter_mut().enumerate() {
let src_idx = pos + (i % src_channels);
*s = if src_idx < state.buffer.len() {
state.buffer[src_idx]
} else {
0.0
};
}
pos += src_channels;
}
state.position.store(pos, Ordering::Relaxed);
}
fn read_samples_i16(state: &PlaybackState, output: &mut [i16]) {
let mut pos = state.position.load(Ordering::Relaxed);
for frame in output.chunks_mut(state.channels as usize) {
if pos >= state.buffer.len() {
for s in frame.iter_mut() {
*s = 0;
}
continue;
}
let src_channels = state.channels as usize;
for (i, s) in frame.iter_mut().enumerate() {
let src_idx = pos + (i % src_channels);
*s = if src_idx < state.buffer.len() {
(state.buffer[src_idx] * 32767.0) as i16
} else {
0
};
}
pos += src_channels;
}
state.position.store(pos, Ordering::Relaxed);
}
fn read_samples_u16(state: &PlaybackState, output: &mut [u16]) {
let mut pos = state.position.load(Ordering::Relaxed);
for frame in output.chunks_mut(state.channels as usize) {
if pos >= state.buffer.len() {
for s in frame.iter_mut() {
*s = 32768;
}
continue;
}
let src_channels = state.channels as usize;
for (i, s) in frame.iter_mut().enumerate() {
let src_idx = pos + (i % src_channels);
*s = if src_idx < state.buffer.len() {
((state.buffer[src_idx] + 1.0) * 32767.0) as u16
} else {
32768
};
}
pos += src_channels;
}
state.position.store(pos, Ordering::Relaxed);
}
/// Play a single sound spec and block until it finishes.
pub fn play_spec_blocking(spec: &soundgen_fmt::SoundSpec) -> Result<(), String> {
let samples = soundgen_fmt::render_spec(spec);
let player = AudioPlayer::play(&samples, spec.sample_rate)?;
while !player.is_finished() {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Ok(())
}
+146
View File
@@ -0,0 +1,146 @@
//! WAV file writer — converts `Vec<f32>` samples to WAV files.
use hound::{SampleFormat, WavSpec, WavWriter};
use std::path::Path;
/// Write interleaved stereo samples to a 16-bit WAV file.
///
/// `samples` is interleaved: [L, R, L, R, ...].
pub fn write_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> {
write_wav_bits(path, samples, sample_rate, 16)
}
/// Write interleaved stereo samples to a WAV file with specified bit depth (16 or 24).
pub fn write_wav_bits(
path: &Path,
samples: &[f32],
sample_rate: u32,
bits_per_sample: u16,
) -> Result<(), String> {
let spec = WavSpec {
channels: 2,
sample_rate,
bits_per_sample,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(path, spec).map_err(|e| format!("create WAV: {}", e))?;
let max_val = (1 << (bits_per_sample - 1)) - 1;
for &sample in samples {
let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * max_val as f32) as i32;
if bits_per_sample == 24 {
writer
.write_sample::<i32>(int_sample)
.map_err(|e| format!("write sample: {}", e))?;
} else {
writer
.write_sample::<i16>(int_sample as i16)
.map_err(|e| format!("write sample: {}", e))?;
}
}
writer
.finalize()
.map_err(|e| format!("finalize WAV: {}", e))?;
Ok(())
}
/// Write mono samples to a WAV file.
pub fn write_wav_mono(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> {
write_wav_mono_bits(path, samples, sample_rate, 16)
}
pub fn write_wav_mono_bits(
path: &Path,
samples: &[f32],
sample_rate: u32,
bits_per_sample: u16,
) -> Result<(), String> {
let spec = WavSpec {
channels: 1,
sample_rate,
bits_per_sample,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(path, spec).map_err(|e| format!("create WAV: {}", e))?;
let max_val = (1 << (bits_per_sample - 1)) - 1;
for &sample in samples {
let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * max_val as f32) as i32;
if bits_per_sample == 24 {
writer
.write_sample::<i32>(int_sample)
.map_err(|e| format!("write sample: {}", e))?;
} else {
writer
.write_sample::<i16>(int_sample as i16)
.map_err(|e| format!("write sample: {}", e))?;
}
}
writer
.finalize()
.map_err(|e| format!("finalize WAV: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_wav_stereo() {
let path = std::env::temp_dir().join("soundgen_test_stereo.wav");
let samples: Vec<f32> = (0..88200)
.map(|i| {
let t = i as f32 / 44100.0;
(t * 440.0 * 2.0 * std::f32::consts::PI).sin() * 0.5
})
.collect();
// Interleave: mono → stereo by duplicating
let stereo: Vec<f32> = samples.iter().flat_map(|&s| [s, s]).collect();
write_wav(&path, &stereo, 44100).unwrap();
assert!(path.exists());
// Verify by reading back
let reader = hound::WavReader::open(&path).unwrap();
let spec = reader.spec();
assert_eq!(spec.channels, 2);
assert_eq!(spec.sample_rate, 44100);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_write_wav_mono() {
let path = std::env::temp_dir().join("soundgen_test_mono.wav");
let samples: Vec<f32> = (0..44100)
.map(|i| {
let t = i as f32 / 44100.0;
(t * 220.0 * 2.0 * std::f32::consts::PI).sin() * 0.5
})
.collect();
write_wav_mono(&path, &samples, 44100).unwrap();
assert!(path.exists());
let reader = hound::WavReader::open(&path).unwrap();
assert_eq!(reader.spec().channels, 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_write_wav_clamps() {
let path = std::env::temp_dir().join("soundgen_test_clamp.wav");
let samples = vec![2.0, -2.0, 1.0, -1.0, 0.0]; // out of range
write_wav_mono(&path, &samples, 44100).unwrap();
assert!(path.exists());
let _ = std::fs::remove_file(&path);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "soundgen-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "soundgen-mcp"
path = "src/bin/mcp.rs"
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
soundgen-io.workspace = true
serde.workspace = true
serde_json.workspace = true
+48
View File
@@ -0,0 +1,48 @@
//! MCP server binary — runs soundgen as an MCP tool server on stdio.
//!
//! Usage: soundgen-mcp [--presets-dir <path>]
//!
//! Configure in your MCP client (e.g., Claude Desktop) as:
//! ```json
//! {
//! "mcpServers": {
//! "soundgen": {
//! "command": "soundgen-mcp",
//! "args": ["--presets-dir", "/path/to/presets"]
//! }
//! }
//! }
//! ```
use std::path::PathBuf;
fn main() {
let mut presets_dir = PathBuf::from("presets");
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--presets-dir" => {
if let Some(dir) = args.next() {
presets_dir = PathBuf::from(dir);
}
}
"--help" | "-h" => {
eprintln!("soundgen-mcp: MCP server for 8-bit sound generation");
eprintln!("Usage: soundgen-mcp [--presets-dir <path>]");
eprintln!();
eprintln!("Tools exposed:");
eprintln!(" list_presets - List available sound presets");
eprintln!(" generate_sfx - Generate WAV from a named preset");
eprintln!(" render_sound - Render WAV from a SoundSpec JSON");
std::process::exit(0);
}
_ => {}
}
}
if let Err(e) = soundgen_mcp::run_server(&presets_dir) {
eprintln!("soundgen-mcp error: {}", e);
std::process::exit(1);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! MCP server for LLM integration.
//!
//! Exposes three tools over stdio (JSON-RPC 2.0 / MCP protocol):
//! - `list_presets`: list available sound presets
//! - `generate_sfx`: generate a WAV from a named preset
//! - `render_sound`: render a WAV from a SoundSpec JSON object
pub mod server;
pub mod tools;
pub use server::run_server;
+157
View File
@@ -0,0 +1,157 @@
//! MCP server — JSON-RPC 2.0 over stdio.
//!
//! Implements the Model Context Protocol for tool exposure to LLMs.
//! Reads JSON-RPC requests from stdin, writes responses to stdout.
use std::io::{self, BufRead, Write};
use soundgen_fmt::PresetRegistry;
use crate::tools;
/// Run the MCP server on stdio. Blocks until stdin is closed.
pub fn run_server(presets_dir: &std::path::Path) -> io::Result<()> {
let registry = PresetRegistry::load_dir(presets_dir).unwrap_or_else(|e| {
eprintln!(
"soundgen-mcp: warning: could not load presets from {}: {}",
presets_dir.display(),
e
);
PresetRegistry::new()
});
let stdin = io::stdin();
let stdout = io::stdout();
let mut stdout = stdout.lock();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let request: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("");
let id = request
.get("id")
.cloned()
.unwrap_or(serde_json::Value::Null);
let response = match method {
"initialize" => handle_initialize(&id),
"notifications/initialized" => serde_json::Value::Null, // notification, no response
"tools/list" => handle_tools_list(&id),
"tools/call" => handle_tools_call(&id, &request, &registry),
_ => {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("Method not found: {}", method)
}
})
}
};
if response != serde_json::Value::Null {
writeln!(stdout, "{}", response)?;
stdout.flush()?;
}
}
Ok(())
}
fn handle_initialize(id: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "soundgen",
"version": env!("CARGO_PKG_VERSION")
}
}
})
}
fn handle_tools_list(id: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"tools": tools::tool_definitions()
}
})
}
fn handle_tools_call(
id: &serde_json::Value,
request: &serde_json::Value,
registry: &PresetRegistry,
) -> serde_json::Value {
let params = request.get("params").unwrap_or(&serde_json::Value::Null);
let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let arguments = params.get("arguments").unwrap_or(&serde_json::Value::Null);
let result = match tool_name {
"list_presets" => {
let category = arguments.get("category").and_then(|c| c.as_str());
tools::list_presets(registry, category)
}
"generate_sfx" => {
let preset = arguments
.get("preset")
.and_then(|p| p.as_str())
.unwrap_or("");
let out_path = arguments
.get("out_path")
.and_then(|p| p.as_str())
.unwrap_or("");
let volume = arguments
.get("volume")
.and_then(|v| v.as_f64())
.map(|v| v as f32);
let duration = arguments
.get("duration")
.and_then(|v| v.as_f64())
.map(|v| v as f32);
tools::generate_sfx(registry, preset, out_path, volume, duration)
}
"render_sound" => {
let spec = arguments.get("spec").unwrap_or(&serde_json::Value::Null);
let spec_json = serde_json::to_string(spec).unwrap_or_default();
let out_path = arguments
.get("out_path")
.and_then(|p| p.as_str())
.unwrap_or("");
tools::render_sound(&spec_json, out_path)
}
_ => tools::ToolResult::err(format!("Unknown tool: {}", tool_name)),
};
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{
"type": "text",
"text": result.text
}],
"isError": result.is_error
}
})
}
+205
View File
@@ -0,0 +1,205 @@
//! Tool implementations for the MCP server.
use soundgen_fmt::{render_spec, PresetCategory, PresetRegistry, SoundSpec};
use soundgen_io::write_wav;
use std::path::Path;
/// Result of a tool call.
pub struct ToolResult {
pub text: String,
pub is_error: bool,
}
impl ToolResult {
pub fn ok(text: String) -> Self {
Self {
text,
is_error: false,
}
}
pub fn err(text: String) -> Self {
Self {
text,
is_error: true,
}
}
}
/// List available presets, optionally filtered by category.
pub fn list_presets(registry: &PresetRegistry, category: Option<&str>) -> ToolResult {
let cat = category.and_then(PresetCategory::from_str);
let presets = registry.list(cat);
if presets.is_empty() {
return ToolResult::ok("No presets available.".to_string());
}
let mut text = format!("Available presets ({}):\n\n", presets.len());
text.push_str(&format!(
"{:<20} {:<10} {:<10} {}\n",
"NAME", "CATEGORY", "DURATION", "CHANNELS"
));
text.push_str(&"-".repeat(60));
text.push('\n');
for p in &presets {
text.push_str(&format!(
"{:<20} {:<10} {:<10.2} {}\n",
p.name,
p.category.as_str(),
p.spec.duration,
p.spec.channels.len()
));
}
text.push_str("\nUse 'generate_sfx' with a preset name to generate a WAV file.");
ToolResult::ok(text)
}
/// Generate a WAV file from a named preset.
pub fn generate_sfx(
registry: &PresetRegistry,
preset_name: &str,
out_path: &str,
volume_override: Option<f32>,
duration_override: Option<f32>,
) -> ToolResult {
let entry = match registry.get(preset_name) {
Some(e) => e,
None => {
let names: Vec<String> = registry.names();
return ToolResult::err(format!(
"Preset '{}' not found. Available: {}",
preset_name,
names.join(", ")
));
}
};
let mut spec = entry.spec.clone();
if let Some(vol) = volume_override {
for ch in &mut spec.channels {
match ch {
soundgen_fmt::ChannelSpec::Pulse { volume, .. } => *volume = vol,
soundgen_fmt::ChannelSpec::Triangle { volume, .. } => *volume = vol,
soundgen_fmt::ChannelSpec::Noise { volume, .. } => *volume = vol,
}
}
}
if let Some(dur) = duration_override {
spec.duration = dur;
}
let samples = render_spec(&spec);
let path = Path::new(out_path);
if let Err(e) = write_wav(path, &samples, spec.sample_rate) {
return ToolResult::err(format!("Failed to write WAV: {}", e));
}
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
ToolResult::ok(format!(
"Generated '{}' → {} ({} samples, {:.2}s, {} Hz, {} bytes)",
preset_name,
out_path,
samples.len() / 2,
spec.duration,
spec.sample_rate,
file_size
))
}
/// Render a WAV file from a SoundSpec JSON object.
pub fn render_sound(spec_json: &str, out_path: &str) -> ToolResult {
let spec: SoundSpec = match serde_json::from_str(spec_json) {
Ok(s) => s,
Err(e) => return ToolResult::err(format!("Invalid SoundSpec JSON: {}", e)),
};
let samples = render_spec(&spec);
let path = Path::new(out_path);
if let Err(e) = write_wav(path, &samples, spec.sample_rate) {
return ToolResult::err(format!("Failed to write WAV: {}", e));
}
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
ToolResult::ok(format!(
"Rendered '{}' → {} ({} samples, {:.2}s, {} Hz, {} bytes)",
spec.name,
out_path,
samples.len() / 2,
spec.duration,
spec.sample_rate,
file_size
))
}
/// Tool definitions for MCP protocol.
pub fn tool_definitions() -> Vec<serde_json::Value> {
vec![
serde_json::json!({
"name": "list_presets",
"description": "List available sound presets. Returns preset names, categories, durations, and channel counts.",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Filter by category: 'sfx', 'ui', or 'ambient'. If omitted, lists all.",
"enum": ["sfx", "ui", "ambient"]
}
}
}
}),
serde_json::json!({
"name": "generate_sfx",
"description": "Generate a WAV sound file from a named preset. The preset must exist in the presets directory.",
"inputSchema": {
"type": "object",
"properties": {
"preset": {
"type": "string",
"description": "Preset name (e.g., 'jump', 'explosion', 'coin', 'click')"
},
"out_path": {
"type": "string",
"description": "Output WAV file path"
},
"volume": {
"type": "number",
"description": "Override volume (0.0-1.0). Optional."
},
"duration": {
"type": "number",
"description": "Override duration in seconds. Optional."
}
},
"required": ["preset", "out_path"]
}
}),
serde_json::json!({
"name": "render_sound",
"description": "Render a WAV file from a custom SoundSpec JSON object. Use this to create sounds that don't match any preset.",
"inputSchema": {
"type": "object",
"properties": {
"spec": {
"type": "object",
"description": "SoundSpec JSON object with name, duration, sample_rate, and channels array"
},
"out_path": {
"type": "string",
"description": "Output WAV file path"
}
},
"required": ["spec", "out_path"]
}
}),
]
}
+12
View File
@@ -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
+216
View File
@@ -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);
}
}
+195
View File
@@ -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);
}
}
}
+31
View File
@@ -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
}
+6
View File
@@ -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;
+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());
}
}