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
Generated
+87
View File
@@ -1362,6 +1362,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -1711,6 +1723,15 @@ dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1726,6 +1747,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hassle-rs"
version = "0.11.0"
@@ -2105,6 +2135,17 @@ dependencies = [
"redox_syscall 0.8.1",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.8"
@@ -2973,6 +3014,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3313,6 +3368,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "soundgen-feedback"
version = "0.1.0"
dependencies = [
"rusqlite",
"serde",
"serde_json",
"soundgen-core",
"soundgen-fmt",
"uuid",
]
[[package]]
name = "soundgen-fmt"
version = "0.1.0"
@@ -3332,8 +3399,10 @@ dependencies = [
"eframe",
"egui",
"egui-file-dialog",
"hound",
"serde_json",
"soundgen-core",
"soundgen-feedback",
"soundgen-fmt",
"soundgen-io",
"soundgen-seq",
@@ -3356,6 +3425,7 @@ dependencies = [
"serde",
"serde_json",
"soundgen-core",
"soundgen-feedback",
"soundgen-fmt",
"soundgen-io",
]
@@ -3711,6 +3781,23 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
+5 -1
View File
@@ -8,6 +8,7 @@ members = [
"crates/soundgen-mcp",
"crates/soundgen-gui",
"crates/soundgen-runtime",
"crates/soundgen-feedback",
]
resolver = "2"
@@ -15,7 +16,7 @@ resolver = "2"
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/emil/soundgen"
repository = "https://github.com/emil28092005/Soundgen"
[workspace.dependencies]
soundgen-core = { path = "crates/soundgen-core" }
@@ -23,6 +24,7 @@ soundgen-fmt = { path = "crates/soundgen-fmt" }
soundgen-io = { path = "crates/soundgen-io" }
soundgen-seq = { path = "crates/soundgen-seq" }
soundgen-runtime = { path = "crates/soundgen-runtime" }
soundgen-feedback = { path = "crates/soundgen-feedback" }
hound = "3"
serde = { version = "1", features = ["derive"] }
@@ -32,3 +34,5 @@ cpal = "0.15"
eframe = "0.28"
egui = "0.28"
egui-file-dialog = "0.6"
rusqlite = { version = "0.31", features = ["bundled"] }
uuid = { version = "1", features = ["v4"] }
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "soundgen-feedback"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
soundgen-core.workspace = true
soundgen-fmt.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
+457
View File
@@ -0,0 +1,457 @@
//! Feedback database — SQLite-backed storage for sound ratings.
//!
//! Stores generated sounds with their specs, WAV paths, and user ratings (1-5 stars).
//! Provides similarity search by name keywords for few-shot reference examples.
use std::path::Path;
use rusqlite::{params, Connection};
use soundgen_fmt::SoundSpec;
/// A feedback entry in the database.
#[derive(Clone, Debug)]
pub struct FeedbackEntry {
pub id: String,
pub name: String,
pub spec: SoundSpec,
pub rating: u8,
pub feedback: Option<String>,
pub wav_path: Option<String>,
pub created_at: String,
}
/// Database statistics.
#[derive(Clone, Debug, Default)]
pub struct DBStats {
pub total: usize,
pub rated: usize,
pub unrated: usize,
pub avg_rating: f32,
}
/// SQLite-backed feedback database.
pub struct FeedbackDB {
conn: Connection,
}
impl FeedbackDB {
/// Open or create a feedback database at the given path.
pub fn open(path: &Path) -> Result<Self, String> {
let conn = Connection::open(path).map_err(|e| format!("open DB: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS feedback (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
spec_json TEXT NOT NULL,
rating INTEGER DEFAULT 0,
feedback TEXT,
wav_path TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_name ON feedback(name);
CREATE INDEX IF NOT EXISTS idx_rating ON feedback(rating);",
)
.map_err(|e| format!("init DB: {}", e))?;
Ok(Self { conn })
}
/// Create an in-memory database (for tests).
pub fn in_memory() -> Result<Self, String> {
let conn = Connection::open_in_memory().map_err(|e| format!("open in-memory DB: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS feedback (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
spec_json TEXT NOT NULL,
rating INTEGER DEFAULT 0,
feedback TEXT,
wav_path TEXT,
created_at TEXT NOT NULL
);",
)
.map_err(|e| format!("init DB: {}", e))?;
Ok(Self { conn })
}
/// Add a new sound entry. Returns the generated ID.
pub fn add(
&mut self,
name: &str,
spec: &SoundSpec,
wav_path: Option<&str>,
) -> Result<String, String> {
let id = uuid::Uuid::new_v4().to_string();
let spec_json =
serde_json::to_string(spec).map_err(|e| format!("serialize spec: {}", e))?;
let now = chrono_now();
self.conn
.execute(
"INSERT INTO feedback (id, name, spec_json, rating, feedback, wav_path, created_at) VALUES (?1, ?2, ?3, 0, NULL, ?4, ?5)",
params![id, name, spec_json, wav_path, now],
)
.map_err(|e| format!("insert: {}", e))?;
Ok(id)
}
/// Update the rating and feedback for an entry.
pub fn update_rating(
&mut self,
id: &str,
rating: u8,
feedback: Option<&str>,
) -> Result<(), String> {
let rating = rating.min(5) as i32;
self.conn
.execute(
"UPDATE feedback SET rating = ?1, feedback = ?2 WHERE id = ?3",
params![rating, feedback, id],
)
.map_err(|e| format!("update: {}", e))?;
Ok(())
}
/// Get all entries, ordered by creation time (newest first).
pub fn get_all(&self) -> Result<Vec<FeedbackEntry>, String> {
let mut stmt = self
.conn
.prepare("SELECT id, name, spec_json, rating, feedback, wav_path, created_at FROM feedback ORDER BY created_at DESC")
.map_err(|e| format!("prepare: {}", e))?;
let rows = stmt
.query_map([], row_to_entry)
.map_err(|e| format!("query: {}", e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("row: {}", e))
}
/// Get only unrated entries (rating = 0).
pub fn get_unrated(&self) -> Result<Vec<FeedbackEntry>, String> {
let mut stmt = self
.conn
.prepare("SELECT id, name, spec_json, rating, feedback, wav_path, created_at FROM feedback WHERE rating = 0 ORDER BY created_at DESC")
.map_err(|e| format!("prepare: {}", e))?;
let rows = stmt
.query_map([], row_to_entry)
.map_err(|e| format!("query: {}", e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("row: {}", e))
}
/// Get the top-rated entries (rating >= min_rating), ordered by rating descending.
pub fn top_rated(&self, limit: usize, min_rating: u8) -> Result<Vec<FeedbackEntry>, String> {
let min_rating = min_rating.min(5) as i32;
let mut stmt = self
.conn
.prepare("SELECT id, name, spec_json, rating, feedback, wav_path, created_at FROM feedback WHERE rating >= ?1 ORDER BY rating DESC, created_at DESC LIMIT ?2")
.map_err(|e| format!("prepare: {}", e))?;
let rows = stmt
.query_map(params![min_rating, limit as i64], row_to_entry)
.map_err(|e| format!("query: {}", e))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("row: {}", e))
}
/// Search for entries with names similar to the given query.
/// Uses keyword matching: splits both query and names on underscores/spaces,
/// counts matching keywords, sorts by match count * rating.
pub fn search_similar(&self, query: &str, limit: usize) -> Result<Vec<FeedbackEntry>, String> {
let query_keywords = tokenize(query);
if query_keywords.is_empty() {
return self.top_rated(limit, 1);
}
let all = self.get_all()?;
let mut scored: Vec<(FeedbackEntry, usize, u8)> = all
.into_iter()
.map(|entry| {
let entry_keywords = tokenize(&entry.name);
let match_count = query_keywords
.iter()
.filter(|qk| entry_keywords.iter().any(|ek| ek == *qk))
.count();
let rating = entry.rating;
(entry, match_count, rating)
})
.filter(|(_, count, _)| *count > 0)
.collect();
// Sort by: match_count desc, then rating desc
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.2.cmp(&a.2)));
Ok(scored.into_iter().take(limit).map(|(e, _, _)| e).collect())
}
/// Get database statistics.
pub fn stats(&self) -> Result<DBStats, String> {
let total: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM feedback", [], |r| r.get(0))
.map_err(|e| format!("count: {}", e))?;
let rated: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM feedback WHERE rating > 0", [], |r| {
r.get(0)
})
.map_err(|e| format!("count: {}", e))?;
let avg: f64 = self
.conn
.query_row(
"SELECT AVG(rating) FROM feedback WHERE rating > 0",
[],
|r| r.get(0),
)
.unwrap_or(0.0);
Ok(DBStats {
total: total as usize,
rated: rated as usize,
unrated: (total - rated) as usize,
avg_rating: avg as f32,
})
}
/// Export rated entries (rating >= min_rating) as JSONL for fine-tuning.
pub fn export_jsonl(&self, path: &Path, min_rating: u8) -> Result<usize, String> {
let entries = self.top_rated(10000, min_rating)?;
let mut content = String::new();
for entry in &entries {
let spec_json =
serde_json::to_string(&entry.spec).map_err(|e| format!("serialize: {}", e))?;
let line = serde_json::json!({
"instruction": format!("Generate a SoundSpec for: {}", entry.name),
"response": spec_json,
"rating": entry.rating,
});
content.push_str(&line.to_string());
content.push('\n');
}
std::fs::write(path, content).map_err(|e| format!("write file: {}", e))?;
Ok(entries.len())
}
/// Delete an entry by ID.
pub fn delete(&mut self, id: &str) -> Result<(), String> {
self.conn
.execute("DELETE FROM feedback WHERE id = ?1", params![id])
.map_err(|e| format!("delete: {}", e))?;
Ok(())
}
}
fn row_to_entry(row: &rusqlite::Row) -> rusqlite::Result<FeedbackEntry> {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let spec_json: String = row.get(2)?;
let rating: i32 = row.get(3)?;
let feedback: Option<String> = row.get(4)?;
let wav_path: Option<String> = row.get(5)?;
let created_at: String = row.get(6)?;
let spec: SoundSpec = serde_json::from_str(&spec_json).unwrap_or_else(|_| SoundSpec::default());
Ok(FeedbackEntry {
id,
name,
spec,
rating: rating.clamp(0, 5) as u8,
feedback,
wav_path,
created_at,
})
}
/// Tokenize a name into lowercase keywords.
/// "missile_launch" → ["missile", "launch"]
/// "Big Explosion" → ["big", "explosion"]
fn tokenize(s: &str) -> Vec<String> {
s.split(|c: char| c == '_' || c == ' ' || c == '-')
.map(|w| w.to_lowercase())
.filter(|w| !w.is_empty() && w.len() > 1)
.collect()
}
fn chrono_now() -> String {
// Simple timestamp without chrono dependency
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{}", secs)
}
#[cfg(test)]
mod tests {
use super::*;
use soundgen_core::FrequencyAutomation;
use soundgen_fmt::ChannelSpec;
fn make_spec(name: &str) -> SoundSpec {
SoundSpec {
name: name.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,
}],
}
}
#[test]
fn test_open_and_add() {
let mut db = FeedbackDB::in_memory().unwrap();
let spec = make_spec("test_sound");
let id = db.add("test_sound", &spec, Some("/tmp/test.wav")).unwrap();
assert!(!id.is_empty());
let all = db.get_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].name, "test_sound");
assert_eq!(all[0].rating, 0);
}
#[test]
fn test_update_rating() {
let mut db = FeedbackDB::in_memory().unwrap();
let spec = make_spec("test");
let id = db.add("test", &spec, None).unwrap();
db.update_rating(&id, 4, Some("good")).unwrap();
let all = db.get_all().unwrap();
assert_eq!(all[0].rating, 4);
assert_eq!(all[0].feedback.as_deref(), Some("good"));
}
#[test]
fn test_get_unrated() {
let mut db = FeedbackDB::in_memory().unwrap();
let id1 = db.add("sound1", &make_spec("sound1"), None).unwrap();
let _id2 = db.add("sound2", &make_spec("sound2"), None).unwrap();
db.update_rating(&id1, 5, None).unwrap();
let unrated = db.get_unrated().unwrap();
assert_eq!(unrated.len(), 1);
assert_eq!(unrated[0].name, "sound2");
}
#[test]
fn test_top_rated() {
let mut db = FeedbackDB::in_memory().unwrap();
let id1 = db.add("best", &make_spec("best"), None).unwrap();
let id2 = db.add("ok", &make_spec("ok"), None).unwrap();
let id3 = db.add("bad", &make_spec("bad"), None).unwrap();
db.update_rating(&id1, 5, None).unwrap();
db.update_rating(&id2, 3, None).unwrap();
db.update_rating(&id3, 1, None).unwrap();
let top = db.top_rated(2, 3).unwrap();
assert_eq!(top.len(), 2);
assert_eq!(top[0].name, "best");
assert_eq!(top[0].rating, 5);
assert_eq!(top[1].name, "ok");
}
#[test]
fn test_search_similar() {
let mut db = FeedbackDB::in_memory().unwrap();
let id1 = db
.add("missile_launch", &make_spec("missile_launch"), None)
.unwrap();
let id2 = db
.add("rocket_launch", &make_spec("rocket_launch"), None)
.unwrap();
let id3 = db
.add("coin_collect", &make_spec("coin_collect"), None)
.unwrap();
db.update_rating(&id1, 5, None).unwrap();
db.update_rating(&id2, 4, None).unwrap();
db.update_rating(&id3, 3, None).unwrap();
// Search "missile launch" should find missile_launch (2 keyword matches) first
let results = db.search_similar("missile_launch", 3).unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].name, "missile_launch");
// Search "launch" should find both missile_launch and rocket_launch
let results = db.search_similar("launch", 3).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_stats() {
let mut db = FeedbackDB::in_memory().unwrap();
let id1 = db.add("s1", &make_spec("s1"), None).unwrap();
let id2 = db.add("s2", &make_spec("s2"), None).unwrap();
db.update_rating(&id1, 4, None).unwrap();
db.update_rating(&id2, 2, None).unwrap();
let stats = db.stats().unwrap();
assert_eq!(stats.total, 2);
assert_eq!(stats.rated, 2);
assert_eq!(stats.unrated, 0);
assert!((stats.avg_rating - 3.0).abs() < 0.1);
}
#[test]
fn test_export_jsonl() {
let mut db = FeedbackDB::in_memory().unwrap();
let id1 = db
.add("good_sound", &make_spec("good_sound"), None)
.unwrap();
db.update_rating(&id1, 5, Some("perfect")).unwrap();
let path = std::env::temp_dir().join("soundgen_test_export.jsonl");
let count = db.export_jsonl(&path, 4).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("good_sound"));
assert!(content.contains("instruction"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_tokenize() {
assert_eq!(tokenize("missile_launch"), vec!["missile", "launch"]);
assert_eq!(tokenize("Big Explosion"), vec!["big", "explosion"]);
assert_eq!(tokenize("coin-collect"), vec!["coin", "collect"]);
assert_eq!(tokenize("a"), Vec::<String>::new());
}
#[test]
fn test_delete() {
let mut db = FeedbackDB::in_memory().unwrap();
let id = db.add("temp", &make_spec("temp"), None).unwrap();
assert_eq!(db.get_all().unwrap().len(), 1);
db.delete(&id).unwrap();
assert_eq!(db.get_all().unwrap().len(), 0);
}
}
+2
View File
@@ -13,7 +13,9 @@ soundgen-core.workspace = true
soundgen-fmt.workspace = true
soundgen-io.workspace = true
soundgen-seq.workspace = true
soundgen-feedback.workspace = true
eframe.workspace = true
egui.workspace = true
egui-file-dialog.workspace = true
serde_json.workspace = true
hound.workspace = true
+358
View File
@@ -13,6 +13,7 @@ use std::time::{Duration, Instant};
use eframe::egui;
use egui_file_dialog::FileDialog;
use soundgen_feedback::{FeedbackDB, FeedbackEntry};
use soundgen_fmt::{render_spec, ChannelSpec, PresetRegistry, SoundSpec};
use soundgen_io::{play, write_wav};
use soundgen_seq::{render_song, Song};
@@ -106,12 +107,21 @@ pub struct SoundgenApp {
open_dialog: FileDialog,
last_keyboard_freq: f32,
last_keyboard_vel: u8,
// Training tab
feedback_db: Option<FeedbackDB>,
feedback_entries: Vec<FeedbackEntry>,
feedback_dirty: bool,
batch_input: String,
batch_dir: String,
rating_text: std::collections::HashMap<String, String>,
feedback_dialog: Option<FileDialog>,
}
#[derive(PartialEq)]
enum Tab {
SfxEditor,
Sequencer,
Training,
}
impl Default for SoundgenApp {
@@ -164,6 +174,15 @@ impl Default for SoundgenApp {
open_dialog: FileDialog::default(),
last_keyboard_freq: 440.0,
last_keyboard_vel: 100,
feedback_db: None,
feedback_entries: vec![],
feedback_dirty: false,
batch_input: String::new(),
batch_dir: std::env::current_dir()
.map(|d| d.join("training_sounds").display().to_string())
.unwrap_or_else(|_| "training_sounds".to_string()),
rating_text: std::collections::HashMap::new(),
feedback_dialog: None,
}
}
}
@@ -313,6 +332,7 @@ impl eframe::App for SoundgenApp {
ui.selectable_value(&mut self.tab, Tab::SfxEditor, "SFX Editor");
ui.selectable_value(&mut self.tab, Tab::Sequencer, "Sequencer");
ui.selectable_value(&mut self.tab, Tab::Training, "Training");
ui.separator();
@@ -447,10 +467,41 @@ impl eframe::App for SoundgenApp {
egui::CentralPanel::default().show(ctx, |ui| match self.tab {
Tab::SfxEditor => self.show_sfx_editor(ui),
Tab::Sequencer => self.show_sequencer(ui),
Tab::Training => self.show_training(ui),
});
// Handle file dialogs
self.handle_file_dialogs(ctx);
// Handle feedback DB dialog
if let Some(mut dialog) = self.feedback_dialog.take() {
dialog.update(ctx);
if let Some(path) = dialog.take_selected() {
match FeedbackDB::open(&path) {
Ok(db) => {
self.feedback_db = Some(db);
self.refresh_feedback();
self.set_status(format!("Opened feedback DB: {}", path.display()));
}
Err(e) => {
// Try creating new DB at this path
match FeedbackDB::open(&path) {
Ok(db) => {
self.feedback_db = Some(db);
self.refresh_feedback();
self.set_status(format!("Created feedback DB: {}", path.display()));
}
Err(e2) => {
self.set_status(format!("DB error: {} / {}", e, e2));
}
}
}
}
} else {
// Still open, put it back
self.feedback_dialog = Some(dialog);
}
}
}
}
@@ -743,6 +794,290 @@ impl SoundgenApp {
});
}
// ── Training Tab ──────────────────────────────────────
fn show_training(&mut self, ui: &mut egui::Ui) {
ui.heading("Training — Rate Sounds for Feedback Loop");
// DB controls
ui.horizontal(|ui| {
if ui.button("Open Feedback DB...").clicked() {
self.open_feedback_db();
}
if ui.button("New Feedback DB...").clicked() {
self.new_feedback_db();
}
if self.feedback_db.is_some() {
if ui.button("Refresh").clicked() {
self.refresh_feedback();
}
if ui.button("Export Dataset...").clicked() {
self.export_dataset();
}
ui.separator();
let stats = self
.feedback_db
.as_ref()
.unwrap()
.stats()
.unwrap_or_default();
ui.label(
egui::RichText::new(format!(
"{} sounds · {} rated · {} unrated · avg {:.1}★",
stats.total, stats.rated, stats.unrated, stats.avg_rating
))
.weak(),
);
}
});
ui.separator();
if self.feedback_db.is_none() {
ui.colored_label(
egui::Color32::from_rgb(120, 120, 140),
"No feedback database open. Click \"Open Feedback DB...\" or \"New Feedback DB...\" to start.",
);
return;
}
// Generate batch section
ui.collapsing("Generate Batch", |ui| {
ui.label("Enter sound names (one per line):");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::multiline(&mut self.batch_input)
.desired_width(300.0)
.desired_rows(5),
);
ui.vertical(|ui| {
ui.label("Output dir:");
ui.text_edit_singleline(&mut self.batch_dir);
if ui.button("Generate").clicked() {
self.generate_batch();
}
});
});
});
ui.separator();
// Refresh entries if needed
if self.feedback_dirty {
self.refresh_feedback();
self.feedback_dirty = false;
}
// Sound list with ratings
let entries = self.feedback_entries.clone();
if entries.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(100, 100, 120),
"No sounds in database. Use \"Generate Batch\" to create some.",
);
return;
}
ui.label(format!("{} sounds in database:", entries.len()));
egui::ScrollArea::vertical().show(ui, |ui| {
for entry in &entries {
ui.group(|ui| {
ui.horizontal(|ui| {
// Name
ui.label(
egui::RichText::new(&entry.name)
.strong()
.color(egui::Color32::from_rgb(140, 180, 255)),
);
// Play button
if ui.button("").on_hover_text("Play").clicked() {
if let Some(wav) = &entry.wav_path {
if let Ok(samples) = load_wav(wav) {
self.start_playback(samples, entry.spec.sample_rate);
}
} else {
// Render on the fly
let samples = render_spec(&entry.spec);
self.start_playback(samples, entry.spec.sample_rate);
}
}
// Duration
ui.label(
egui::RichText::new(format!("{:.2}s", entry.spec.duration))
.small()
.weak(),
);
ui.separator();
// Star rating
let id = entry.id.clone();
let mut rating = entry.rating;
ui.horizontal(|ui| {
for star in 1..=5u8 {
let filled = star <= rating;
let star_text = if filled { "" } else { "" };
let color = if filled {
egui::Color32::from_rgb(255, 200, 80)
} else {
egui::Color32::from_rgb(80, 80, 90)
};
if ui
.add(
egui::Button::new(
egui::RichText::new(star_text).color(color),
)
.frame(false),
)
.on_hover_text(format!(
"Rate {} star{}",
star,
if star > 1 { "s" } else { "" }
))
.clicked()
{
rating = if rating == star { 0 } else { star };
if let Some(db) = &mut self.feedback_db {
let _ = db.update_rating(&id, rating, None);
}
self.feedback_dirty = true;
}
}
});
// Feedback text
let text = self
.rating_text
.entry(id.clone())
.or_insert_with(|| entry.feedback.clone().unwrap_or_default());
ui.add(
egui::TextEdit::singleline(text)
.desired_width(150.0)
.hint_text("feedback..."),
);
// Save feedback button
if ui.button("").on_hover_text("Save feedback").clicked() {
let fb = self.rating_text.get(&id).cloned();
let fb_opt = if fb.as_deref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
fb
};
if let Some(db) = &mut self.feedback_db {
let _ = db.update_rating(&id, entry.rating, fb_opt.as_deref());
}
self.set_status("Feedback saved".to_string());
}
// Delete button
if ui.button("🗑").on_hover_text("Delete").clicked() {
if let Some(db) = &mut self.feedback_db {
let _ = db.delete(&id);
}
self.feedback_dirty = true;
}
});
});
}
});
}
fn open_feedback_db(&mut self) {
let mut dialog = FileDialog::default();
dialog.select_file();
// Store dialog for next frame processing
self.feedback_dialog = Some(dialog);
}
fn new_feedback_db(&mut self) {
let mut dialog = FileDialog::default();
dialog.save_file();
self.feedback_dialog = Some(dialog);
}
fn refresh_feedback(&mut self) {
if let Some(db) = &self.feedback_db {
self.feedback_entries = db.get_all().unwrap_or_default();
}
}
fn generate_batch(&mut self) {
let names: Vec<String> = self
.batch_input
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if names.is_empty() {
self.set_status("No names entered".to_string());
return;
}
let dir = std::path::Path::new(&self.batch_dir);
let _ = std::fs::create_dir_all(dir);
let mut count = 0;
for name in &names {
let spec = SoundSpec {
name: name.clone(),
duration: 0.3,
sample_rate: 44100,
channels: vec![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 write_wav(&wav_path, &samples, 44100).is_ok() {
if let Some(db) = &mut self.feedback_db {
let _ = db.add(name, &spec, Some(&wav_path.display().to_string()));
}
count += 1;
}
}
self.feedback_dirty = true;
self.set_status(format!("Generated {} sounds to DB", count));
}
fn export_dataset(&mut self) {
if let Some(db) = &self.feedback_db {
let path = std::path::Path::new("feedback_dataset.jsonl");
match db.export_jsonl(path, 4) {
Ok(count) => {
self.set_status(format!(
"Exported {} rated examples to {}",
count,
path.display()
));
}
Err(e) => {
self.set_status(format!("Export error: {}", e));
}
}
}
}
// ── Playback ──────────────────────────────────────────
fn play_current(&mut self) {
@@ -910,3 +1245,26 @@ fn downsample_for_display(samples: &[f32]) -> Vec<f32> {
mono
}
}
/// Load a WAV file and return interleaved stereo samples.
fn load_wav(path: &str) -> Result<Vec<f32>, String> {
let mut reader = hound::WavReader::open(path).map_err(|e| format!("open wav: {}", e))?;
let spec = reader.spec();
let samples: Vec<i16> = reader.samples::<i16>().filter_map(|s| s.ok()).collect();
let mut out = Vec::with_capacity(samples.len());
for s in samples {
out.push(s as f32 / 32767.0);
}
if spec.channels == 1 {
let mut stereo = Vec::with_capacity(out.len() * 2);
for s in out {
stereo.push(s);
stereo.push(s);
}
return Ok(stereo);
}
Ok(out)
}
+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"]
}
}),
]
}