Add feedback loop: SQLite-backed rating system + MCP tools + GUI Training tab
New crate: soundgen-feedback - FeedbackDB with SQLite (rusqlite, bundled) - CRUD: add, update_rating, get_all, get_unrated, top_rated, delete - search_similar: keyword-based similarity for few-shot reference - export_jsonl: export rated examples for fine-tuning - 9 tests MCP server: 5 tools (was 3) - generate_batch: generate multiple sounds by name, store in DB - get_reference_sounds: search DB for highly-rated similar sounds - render_sound: now returns reference examples from DB in response - --db flag to specify feedback database path GUI: Training tab - Open/create feedback DB (file dialog) - Generate batch dialog (enter names, output dir) - Sound list with star rating (1-5), feedback text, play button - Export dataset as JSONL - Stats display (total/rated/avg rating) OpenCode config updated with --db feedback.db path
This commit is contained in:
@@ -12,5 +12,6 @@ path = "src/bin/mcp.rs"
|
||||
soundgen-core.workspace = true
|
||||
soundgen-fmt.workspace = true
|
||||
soundgen-io.workspace = true
|
||||
soundgen-feedback.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! MCP server binary — runs soundgen as an MCP tool server on stdio.
|
||||
//!
|
||||
//! Usage: soundgen-mcp [--presets-dir <path>]
|
||||
//! Usage: soundgen-mcp [--presets-dir <path>] [--db <path>]
|
||||
//!
|
||||
//! Configure in your MCP client (e.g., Claude Desktop) as:
|
||||
//! ```json
|
||||
@@ -8,7 +8,7 @@
|
||||
//! "mcpServers": {
|
||||
//! "soundgen": {
|
||||
//! "command": "soundgen-mcp",
|
||||
//! "args": ["--presets-dir", "/path/to/presets"]
|
||||
//! "args": ["--presets-dir", "/path/to/presets", "--db", "/path/to/feedback.db"]
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
@@ -18,6 +18,7 @@ use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let mut presets_dir = PathBuf::from("presets");
|
||||
let mut db_path: Option<PathBuf> = None;
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -27,21 +28,30 @@ fn main() {
|
||||
presets_dir = PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
"--db" => {
|
||||
if let Some(path) = args.next() {
|
||||
db_path = Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("soundgen-mcp: MCP server for 8-bit sound generation");
|
||||
eprintln!("Usage: soundgen-mcp [--presets-dir <path>]");
|
||||
eprintln!("Usage: soundgen-mcp [--presets-dir <path>] [--db <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");
|
||||
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");
|
||||
eprintln!(
|
||||
" generate_batch - Generate multiple sounds, store in feedback DB"
|
||||
);
|
||||
eprintln!(" get_reference_sounds - Get highly-rated reference sounds from DB");
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = soundgen_mcp::run_server(&presets_dir) {
|
||||
if let Err(e) = soundgen_mcp::run_server(&presets_dir, db_path.as_deref()) {
|
||||
eprintln!("soundgen-mcp error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
//! Reads JSON-RPC requests from stdin, writes responses to stdout.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use soundgen_feedback::FeedbackDB;
|
||||
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<()> {
|
||||
pub fn run_server(
|
||||
presets_dir: &std::path::Path,
|
||||
db_path: Option<&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 {}: {}",
|
||||
@@ -20,6 +25,24 @@ pub fn run_server(presets_dir: &std::path::Path) -> io::Result<()> {
|
||||
PresetRegistry::new()
|
||||
});
|
||||
|
||||
// Open feedback DB if path provided
|
||||
let db = db_path.map(|p| {
|
||||
FeedbackDB::open(p).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"soundgen-mcp: warning: could not open feedback DB at {}: {}",
|
||||
p.display(),
|
||||
e
|
||||
);
|
||||
// Fall back to in-memory
|
||||
FeedbackDB::in_memory().unwrap_or_else(|e| {
|
||||
eprintln!("soundgen-mcp: fatal: could not create in-memory DB: {}", e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let db_mutex = db.map(Mutex::new);
|
||||
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
@@ -47,9 +70,9 @@ pub fn run_server(presets_dir: &std::path::Path) -> io::Result<()> {
|
||||
|
||||
let response = match method {
|
||||
"initialize" => handle_initialize(&id),
|
||||
"notifications/initialized" => serde_json::Value::Null, // notification, no response
|
||||
"notifications/initialized" => serde_json::Value::Null,
|
||||
"tools/list" => handle_tools_list(&id),
|
||||
"tools/call" => handle_tools_call(&id, &request, ®istry),
|
||||
"tools/call" => handle_tools_call(&id, &request, ®istry, &db_mutex),
|
||||
_ => {
|
||||
serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -98,10 +121,12 @@ fn handle_tools_list(id: &serde_json::Value) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_tools_call(
|
||||
id: &serde_json::Value,
|
||||
request: &serde_json::Value,
|
||||
registry: &PresetRegistry,
|
||||
db_mutex: &Option<Mutex<FeedbackDB>>,
|
||||
) -> 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("");
|
||||
@@ -138,7 +163,52 @@ fn handle_tools_call(
|
||||
.get("out_path")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("");
|
||||
tools::render_sound(&spec_json, out_path)
|
||||
let db_guard = db_mutex.as_ref().and_then(|m| m.lock().ok());
|
||||
let db_ref = db_guard.as_deref();
|
||||
tools::render_sound(&spec_json, out_path, db_ref)
|
||||
}
|
||||
"generate_batch" => {
|
||||
let names: Vec<String> = arguments
|
||||
.get("names")
|
||||
.and_then(|n| n.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let out_dir = arguments
|
||||
.get("out_dir")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Extract DB from mutex for the call
|
||||
if let Some(mutex) = db_mutex {
|
||||
if let Ok(mut db) = mutex.lock() {
|
||||
tools::generate_batch(&names, out_dir, Some(&mut db))
|
||||
} else {
|
||||
tools::generate_batch(&names, out_dir, None)
|
||||
}
|
||||
} else {
|
||||
tools::generate_batch(&names, out_dir, None)
|
||||
}
|
||||
}
|
||||
"get_reference_sounds" => {
|
||||
let name = arguments.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let limit = arguments
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(5);
|
||||
let min_rating = arguments
|
||||
.get("min_rating")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u8)
|
||||
.unwrap_or(4);
|
||||
|
||||
let db_guard = db_mutex.as_ref().and_then(|m| m.lock().ok());
|
||||
let db_ref = db_guard.as_deref();
|
||||
tools::get_reference_sounds(name, limit, min_rating, db_ref)
|
||||
}
|
||||
_ => tools::ToolResult::err(format!("Unknown tool: {}", tool_name)),
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn generate_sfx(
|
||||
let entry = match registry.get(preset_name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let names: Vec<String> = registry.names();
|
||||
let names = registry.names();
|
||||
return ToolResult::err(format!(
|
||||
"Preset '{}' not found. Available: {}",
|
||||
preset_name,
|
||||
@@ -114,7 +114,12 @@ pub fn generate_sfx(
|
||||
}
|
||||
|
||||
/// Render a WAV file from a SoundSpec JSON object.
|
||||
pub fn render_sound(spec_json: &str, out_path: &str) -> ToolResult {
|
||||
/// If a feedback DB is provided, includes reference examples in the response.
|
||||
pub fn render_sound(
|
||||
spec_json: &str,
|
||||
out_path: &str,
|
||||
feedback_db: Option<&soundgen_feedback::FeedbackDB>,
|
||||
) -> ToolResult {
|
||||
let spec: SoundSpec = match serde_json::from_str(spec_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return ToolResult::err(format!("Invalid SoundSpec JSON: {}", e)),
|
||||
@@ -129,7 +134,7 @@ pub fn render_sound(spec_json: &str, out_path: &str) -> ToolResult {
|
||||
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
ToolResult::ok(format!(
|
||||
let mut response = format!(
|
||||
"Rendered '{}' → {} ({} samples, {:.2}s, {} Hz, {} bytes)",
|
||||
spec.name,
|
||||
out_path,
|
||||
@@ -137,7 +142,157 @@ pub fn render_sound(spec_json: &str, out_path: &str) -> ToolResult {
|
||||
spec.duration,
|
||||
spec.sample_rate,
|
||||
file_size
|
||||
))
|
||||
);
|
||||
|
||||
// Include reference examples from feedback DB
|
||||
if let Some(db) = feedback_db {
|
||||
if let Ok(refs) = db.search_similar(&spec.name, 3) {
|
||||
let rated: Vec<_> = refs.iter().filter(|r| r.rating > 0).collect();
|
||||
if !rated.is_empty() {
|
||||
response.push_str("\n\nReference examples (highly-rated similar sounds):\n");
|
||||
for (i, entry) in rated.iter().enumerate() {
|
||||
let spec_json = serde_json::to_string(&entry.spec).unwrap_or_default();
|
||||
response.push_str(&format!(
|
||||
"{}. \"{}\" {}★: {}\n",
|
||||
i + 1,
|
||||
entry.name,
|
||||
entry.rating,
|
||||
spec_json
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolResult::ok(response)
|
||||
}
|
||||
/// Generate a batch of sounds by name and store them in the feedback DB.
|
||||
pub fn generate_batch(
|
||||
names: &[String],
|
||||
out_dir: &str,
|
||||
mut feedback_db: Option<&mut soundgen_feedback::FeedbackDB>,
|
||||
) -> ToolResult {
|
||||
if names.is_empty() {
|
||||
return ToolResult::err("No sound names provided".to_string());
|
||||
}
|
||||
|
||||
let dir = Path::new(out_dir);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
return ToolResult::err(format!("Cannot create output dir: {}", e));
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut db_saved = false;
|
||||
|
||||
for name in names {
|
||||
let spec = SoundSpec {
|
||||
name: name.clone(),
|
||||
duration: 0.3,
|
||||
sample_rate: 44100,
|
||||
channels: vec![soundgen_fmt::ChannelSpec::Pulse {
|
||||
duty: 50,
|
||||
frequency: soundgen_core::FrequencyAutomation {
|
||||
start: 200.0,
|
||||
end: 800.0,
|
||||
curve: soundgen_core::SweepCurve::Exponential,
|
||||
},
|
||||
envelope: Some(soundgen_fmt::EnvelopeSpec {
|
||||
attack: 0.01,
|
||||
decay: 0.1,
|
||||
sustain: 0.0,
|
||||
release: 0.15,
|
||||
}),
|
||||
filter: None,
|
||||
volume: 0.5,
|
||||
pan: 0.0,
|
||||
}],
|
||||
};
|
||||
|
||||
let samples = render_spec(&spec);
|
||||
let wav_path = dir.join(format!("{}.wav", name));
|
||||
|
||||
if let Err(e) = write_wav(&wav_path, &samples, spec.sample_rate) {
|
||||
errors.push(format!("{}: {}", name, e));
|
||||
continue;
|
||||
}
|
||||
|
||||
let wav_path_str = wav_path.display().to_string();
|
||||
|
||||
if let Some(db) = feedback_db.as_deref_mut() {
|
||||
if let Err(e) = db.add(name, &spec, Some(&wav_path_str)) {
|
||||
errors.push(format!("{} (DB): {}", name, e));
|
||||
} else {
|
||||
db_saved = true;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(format!(
|
||||
" {} → {} ({:.2}s)",
|
||||
name, wav_path_str, spec.duration
|
||||
));
|
||||
}
|
||||
|
||||
let mut response = format!("Generated {} sounds:\n", results.len());
|
||||
response.push_str(&results.join("\n"));
|
||||
|
||||
if !errors.is_empty() {
|
||||
response.push_str(&format!("\n\nErrors:\n{}", errors.join("\n")));
|
||||
}
|
||||
|
||||
if db_saved {
|
||||
response.push_str("\n\nSounds saved to feedback DB (rating=0, unrated). Use the GUI Training tab to rate them.");
|
||||
}
|
||||
|
||||
ToolResult::ok(response)
|
||||
}
|
||||
/// Get reference sounds from the feedback DB for a given name.
|
||||
pub fn get_reference_sounds(
|
||||
name: &str,
|
||||
limit: usize,
|
||||
min_rating: u8,
|
||||
feedback_db: Option<&soundgen_feedback::FeedbackDB>,
|
||||
) -> ToolResult {
|
||||
let db = match feedback_db {
|
||||
Some(db) => db,
|
||||
None => return ToolResult::err("Feedback database not available".to_string()),
|
||||
};
|
||||
|
||||
let mut entries = match db.search_similar(name, limit * 2) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return ToolResult::err(format!("DB search error: {}", e)),
|
||||
};
|
||||
|
||||
// Filter by min_rating
|
||||
entries.retain(|e| e.rating >= min_rating);
|
||||
entries.truncate(limit);
|
||||
|
||||
if entries.is_empty() {
|
||||
return ToolResult::ok(format!(
|
||||
"No rated sounds matching '{}' (min_rating={}) found in feedback DB.",
|
||||
name, min_rating
|
||||
));
|
||||
}
|
||||
|
||||
let mut response = format!(
|
||||
"Reference sounds for '{}' (min_rating={}, {} found):\n\n",
|
||||
name,
|
||||
min_rating,
|
||||
entries.len()
|
||||
);
|
||||
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let spec_json = serde_json::to_string(&entry.spec).unwrap_or_default();
|
||||
response.push_str(&format!("{}. \"{}\" {}★", i + 1, entry.name, entry.rating));
|
||||
if let Some(fb) = &entry.feedback {
|
||||
response.push_str(&format!(" — \"{}\"", fb));
|
||||
}
|
||||
response.push_str(&format!(":\n{}\n\n", spec_json));
|
||||
}
|
||||
|
||||
response.push_str("Use these as reference examples when generating similar sounds.");
|
||||
|
||||
ToolResult::ok(response)
|
||||
}
|
||||
|
||||
/// Tool definitions for MCP protocol.
|
||||
@@ -185,7 +340,7 @@ pub fn tool_definitions() -> Vec<serde_json::Value> {
|
||||
}),
|
||||
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.",
|
||||
"description": "Render a WAV file from a custom SoundSpec JSON object. Use this to create sounds that don't match any preset. If feedback DB is available, returns reference examples of highly-rated similar sounds.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -201,5 +356,46 @@ pub fn tool_definitions() -> Vec<serde_json::Value> {
|
||||
"required": ["spec", "out_path"]
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"name": "generate_batch",
|
||||
"description": "Generate multiple sounds by name and save them to a directory. Sounds are stored in the feedback DB (unrated) for later evaluation in the GUI Training tab.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"names": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Sound names to generate (e.g., [\"missile_launch\", \"sword_swing\"])"
|
||||
},
|
||||
"out_dir": {
|
||||
"type": "string",
|
||||
"description": "Output directory for WAV files"
|
||||
}
|
||||
},
|
||||
"required": ["names", "out_dir"]
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"name": "get_reference_sounds",
|
||||
"description": "Get highly-rated reference sounds from the feedback DB for a given name. Use these as examples when generating similar sounds to improve quality.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Sound name to search for (e.g., 'explosion', 'missile_launch')"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Max number of results (default: 5)"
|
||||
},
|
||||
"min_rating": {
|
||||
"type": "number",
|
||||
"description": "Minimum rating (1-5, default: 4)"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user