diff --git a/Cargo.lock b/Cargo.lock index 1a12e0d..ac60db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ebe3aba..f8b464a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/soundgen-feedback/Cargo.toml b/crates/soundgen-feedback/Cargo.toml new file mode 100644 index 0000000..756973a --- /dev/null +++ b/crates/soundgen-feedback/Cargo.toml @@ -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 diff --git a/crates/soundgen-feedback/src/lib.rs b/crates/soundgen-feedback/src/lib.rs new file mode 100644 index 0000000..7859ff7 --- /dev/null +++ b/crates/soundgen-feedback/src/lib.rs @@ -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, + pub wav_path: Option, + 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 { + 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 { + 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 { + 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, 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::, _>>() + .map_err(|e| format!("row: {}", e)) + } + + /// Get only unrated entries (rating = 0). + pub fn get_unrated(&self) -> Result, 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::, _>>() + .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, 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::, _>>() + .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, 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 { + 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 { + 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 { + 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 = row.get(4)?; + let wav_path: Option = 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 { + 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::::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); + } +} diff --git a/crates/soundgen-gui/Cargo.toml b/crates/soundgen-gui/Cargo.toml index 9a32e2d..eb9c75c 100644 --- a/crates/soundgen-gui/Cargo.toml +++ b/crates/soundgen-gui/Cargo.toml @@ -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 diff --git a/crates/soundgen-gui/src/app.rs b/crates/soundgen-gui/src/app.rs index faecca3..500dfba 100644 --- a/crates/soundgen-gui/src/app.rs +++ b/crates/soundgen-gui/src/app.rs @@ -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, + feedback_entries: Vec, + feedback_dirty: bool, + batch_input: String, + batch_dir: String, + rating_text: std::collections::HashMap, + feedback_dialog: Option, } #[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 = 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 { mono } } + +/// Load a WAV file and return interleaved stereo samples. +fn load_wav(path: &str) -> Result, String> { + let mut reader = hound::WavReader::open(path).map_err(|e| format!("open wav: {}", e))?; + let spec = reader.spec(); + let samples: Vec = reader.samples::().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) +} diff --git a/crates/soundgen-mcp/Cargo.toml b/crates/soundgen-mcp/Cargo.toml index 33d3536..b7c9220 100644 --- a/crates/soundgen-mcp/Cargo.toml +++ b/crates/soundgen-mcp/Cargo.toml @@ -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 diff --git a/crates/soundgen-mcp/src/bin/mcp.rs b/crates/soundgen-mcp/src/bin/mcp.rs index 602bcee..10c5d0f 100644 --- a/crates/soundgen-mcp/src/bin/mcp.rs +++ b/crates/soundgen-mcp/src/bin/mcp.rs @@ -1,6 +1,6 @@ //! MCP server binary — runs soundgen as an MCP tool server on stdio. //! -//! Usage: soundgen-mcp [--presets-dir ] +//! Usage: soundgen-mcp [--presets-dir ] [--db ] //! //! 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 = 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 ]"); + eprintln!("Usage: soundgen-mcp [--presets-dir ] [--db ]"); 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); } diff --git a/crates/soundgen-mcp/src/server.rs b/crates/soundgen-mcp/src/server.rs index 31046bd..30e9a1c 100644 --- a/crates/soundgen-mcp/src/server.rs +++ b/crates/soundgen-mcp/src/server.rs @@ -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>, ) -> 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 = 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)), }; diff --git a/crates/soundgen-mcp/src/tools.rs b/crates/soundgen-mcp/src/tools.rs index be2c9a6..8eb7f54 100644 --- a/crates/soundgen-mcp/src/tools.rs +++ b/crates/soundgen-mcp/src/tools.rs @@ -68,7 +68,7 @@ pub fn generate_sfx( let entry = match registry.get(preset_name) { Some(e) => e, None => { - let names: Vec = 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::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 { "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"] + } + }), ] }