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
204 lines
6.0 KiB
Rust
204 lines
6.0 KiB
Rust
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, ¶m, &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(())
|
|
}
|