Files
Soundgen/crates/soundgen-cli/src/main.rs
T
Emil c7d6c40683 Initial release: 8-bit sound synthesizer with LLM/MCP integration
Soundgen is a Rust workspace for generating 8-bit/chiptune sound effects,
UI sounds, and ambient textures for game audio assets. It features JSON-first
sound specs, an MCP server for LLM tool-use, an egui GUI editor, and a
runtime library with NES-authentic DAC emulation.

Features:
- 6 voice types: pulse, triangle, noise, DPCM, wavetable, FM
- Effects: ADSR envelope, frequency sweep, biquad filter, vibrato
- 13 built-in presets (SFX/UI/ambient) as JSON data files
- MCP server: list_presets, generate_sfx, render_sound tools
- Pattern-based sequencer (JSON song format)
- egui GUI: virtual keyboard, preset browser, channel editor, undo/redo
- Runtime: NES nonlinear DAC + SoundBank for game embedding
- 90 tests, 0 warnings

Crates:
- soundgen-core: synthesis engine (no I/O)
- soundgen-fmt: SoundSpec JSON schema + PresetRegistry
- soundgen-io: WAV writer + audio playback
- soundgen-seq: sequencer (patterns, songs)
- soundgen-cli: gen/render/render-song/list-presets
- soundgen-mcp: MCP server for LLM integration
- soundgen-gui: egui editor
- soundgen-runtime: NES DAC + SoundBank
2026-06-21 22:07:05 +03:00

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, &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(())
}