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