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:
Emil
2026-06-21 22:40:07 +03:00
parent c7d6c40683
commit 3e2376ac26
10 changed files with 1215 additions and 17 deletions
+1
View File
@@ -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
+17 -7
View File
@@ -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);
}
+74 -4
View File
@@ -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, &registry),
"tools/call" => handle_tools_call(&id, &request, &registry, &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)),
};
+201 -5
View File
@@ -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"]
}
}),
]
}