Add feedback loop: SQLite-backed rating system + MCP tools + GUI Training tab

New crate: soundgen-feedback
- FeedbackDB with SQLite (rusqlite, bundled)
- CRUD: add, update_rating, get_all, get_unrated, top_rated, delete
- search_similar: keyword-based similarity for few-shot reference
- export_jsonl: export rated examples for fine-tuning
- 9 tests

MCP server: 5 tools (was 3)
- generate_batch: generate multiple sounds by name, store in DB
- get_reference_sounds: search DB for highly-rated similar sounds
- render_sound: now returns reference examples from DB in response
- --db flag to specify feedback database path

GUI: Training tab
- Open/create feedback DB (file dialog)
- Generate batch dialog (enter names, output dir)
- Sound list with star rating (1-5), feedback text, play button
- Export dataset as JSONL
- Stats display (total/rated/avg rating)

OpenCode config updated with --db feedback.db path
This commit is contained in:
Emil
2026-06-21 22:40:07 +03:00
parent c7d6c40683
commit 3e2376ac26
10 changed files with 1215 additions and 17 deletions
+2
View File
@@ -13,7 +13,9 @@ soundgen-core.workspace = true
soundgen-fmt.workspace = true
soundgen-io.workspace = true
soundgen-seq.workspace = true
soundgen-feedback.workspace = true
eframe.workspace = true
egui.workspace = true
egui-file-dialog.workspace = true
serde_json.workspace = true
hound.workspace = true
+358
View File
@@ -13,6 +13,7 @@ use std::time::{Duration, Instant};
use eframe::egui;
use egui_file_dialog::FileDialog;
use soundgen_feedback::{FeedbackDB, FeedbackEntry};
use soundgen_fmt::{render_spec, ChannelSpec, PresetRegistry, SoundSpec};
use soundgen_io::{play, write_wav};
use soundgen_seq::{render_song, Song};
@@ -106,12 +107,21 @@ pub struct SoundgenApp {
open_dialog: FileDialog,
last_keyboard_freq: f32,
last_keyboard_vel: u8,
// Training tab
feedback_db: Option<FeedbackDB>,
feedback_entries: Vec<FeedbackEntry>,
feedback_dirty: bool,
batch_input: String,
batch_dir: String,
rating_text: std::collections::HashMap<String, String>,
feedback_dialog: Option<FileDialog>,
}
#[derive(PartialEq)]
enum Tab {
SfxEditor,
Sequencer,
Training,
}
impl Default for SoundgenApp {
@@ -164,6 +174,15 @@ impl Default for SoundgenApp {
open_dialog: FileDialog::default(),
last_keyboard_freq: 440.0,
last_keyboard_vel: 100,
feedback_db: None,
feedback_entries: vec![],
feedback_dirty: false,
batch_input: String::new(),
batch_dir: std::env::current_dir()
.map(|d| d.join("training_sounds").display().to_string())
.unwrap_or_else(|_| "training_sounds".to_string()),
rating_text: std::collections::HashMap::new(),
feedback_dialog: None,
}
}
}
@@ -313,6 +332,7 @@ impl eframe::App for SoundgenApp {
ui.selectable_value(&mut self.tab, Tab::SfxEditor, "SFX Editor");
ui.selectable_value(&mut self.tab, Tab::Sequencer, "Sequencer");
ui.selectable_value(&mut self.tab, Tab::Training, "Training");
ui.separator();
@@ -447,10 +467,41 @@ impl eframe::App for SoundgenApp {
egui::CentralPanel::default().show(ctx, |ui| match self.tab {
Tab::SfxEditor => self.show_sfx_editor(ui),
Tab::Sequencer => self.show_sequencer(ui),
Tab::Training => self.show_training(ui),
});
// Handle file dialogs
self.handle_file_dialogs(ctx);
// Handle feedback DB dialog
if let Some(mut dialog) = self.feedback_dialog.take() {
dialog.update(ctx);
if let Some(path) = dialog.take_selected() {
match FeedbackDB::open(&path) {
Ok(db) => {
self.feedback_db = Some(db);
self.refresh_feedback();
self.set_status(format!("Opened feedback DB: {}", path.display()));
}
Err(e) => {
// Try creating new DB at this path
match FeedbackDB::open(&path) {
Ok(db) => {
self.feedback_db = Some(db);
self.refresh_feedback();
self.set_status(format!("Created feedback DB: {}", path.display()));
}
Err(e2) => {
self.set_status(format!("DB error: {} / {}", e, e2));
}
}
}
}
} else {
// Still open, put it back
self.feedback_dialog = Some(dialog);
}
}
}
}
@@ -743,6 +794,290 @@ impl SoundgenApp {
});
}
// ── Training Tab ──────────────────────────────────────
fn show_training(&mut self, ui: &mut egui::Ui) {
ui.heading("Training — Rate Sounds for Feedback Loop");
// DB controls
ui.horizontal(|ui| {
if ui.button("Open Feedback DB...").clicked() {
self.open_feedback_db();
}
if ui.button("New Feedback DB...").clicked() {
self.new_feedback_db();
}
if self.feedback_db.is_some() {
if ui.button("Refresh").clicked() {
self.refresh_feedback();
}
if ui.button("Export Dataset...").clicked() {
self.export_dataset();
}
ui.separator();
let stats = self
.feedback_db
.as_ref()
.unwrap()
.stats()
.unwrap_or_default();
ui.label(
egui::RichText::new(format!(
"{} sounds · {} rated · {} unrated · avg {:.1}★",
stats.total, stats.rated, stats.unrated, stats.avg_rating
))
.weak(),
);
}
});
ui.separator();
if self.feedback_db.is_none() {
ui.colored_label(
egui::Color32::from_rgb(120, 120, 140),
"No feedback database open. Click \"Open Feedback DB...\" or \"New Feedback DB...\" to start.",
);
return;
}
// Generate batch section
ui.collapsing("Generate Batch", |ui| {
ui.label("Enter sound names (one per line):");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::multiline(&mut self.batch_input)
.desired_width(300.0)
.desired_rows(5),
);
ui.vertical(|ui| {
ui.label("Output dir:");
ui.text_edit_singleline(&mut self.batch_dir);
if ui.button("Generate").clicked() {
self.generate_batch();
}
});
});
});
ui.separator();
// Refresh entries if needed
if self.feedback_dirty {
self.refresh_feedback();
self.feedback_dirty = false;
}
// Sound list with ratings
let entries = self.feedback_entries.clone();
if entries.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(100, 100, 120),
"No sounds in database. Use \"Generate Batch\" to create some.",
);
return;
}
ui.label(format!("{} sounds in database:", entries.len()));
egui::ScrollArea::vertical().show(ui, |ui| {
for entry in &entries {
ui.group(|ui| {
ui.horizontal(|ui| {
// Name
ui.label(
egui::RichText::new(&entry.name)
.strong()
.color(egui::Color32::from_rgb(140, 180, 255)),
);
// Play button
if ui.button("▶").on_hover_text("Play").clicked() {
if let Some(wav) = &entry.wav_path {
if let Ok(samples) = load_wav(wav) {
self.start_playback(samples, entry.spec.sample_rate);
}
} else {
// Render on the fly
let samples = render_spec(&entry.spec);
self.start_playback(samples, entry.spec.sample_rate);
}
}
// Duration
ui.label(
egui::RichText::new(format!("{:.2}s", entry.spec.duration))
.small()
.weak(),
);
ui.separator();
// Star rating
let id = entry.id.clone();
let mut rating = entry.rating;
ui.horizontal(|ui| {
for star in 1..=5u8 {
let filled = star <= rating;
let star_text = if filled { "★" } else { "☆" };
let color = if filled {
egui::Color32::from_rgb(255, 200, 80)
} else {
egui::Color32::from_rgb(80, 80, 90)
};
if ui
.add(
egui::Button::new(
egui::RichText::new(star_text).color(color),
)
.frame(false),
)
.on_hover_text(format!(
"Rate {} star{}",
star,
if star > 1 { "s" } else { "" }
))
.clicked()
{
rating = if rating == star { 0 } else { star };
if let Some(db) = &mut self.feedback_db {
let _ = db.update_rating(&id, rating, None);
}
self.feedback_dirty = true;
}
}
});
// Feedback text
let text = self
.rating_text
.entry(id.clone())
.or_insert_with(|| entry.feedback.clone().unwrap_or_default());
ui.add(
egui::TextEdit::singleline(text)
.desired_width(150.0)
.hint_text("feedback..."),
);
// Save feedback button
if ui.button("✓").on_hover_text("Save feedback").clicked() {
let fb = self.rating_text.get(&id).cloned();
let fb_opt = if fb.as_deref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
fb
};
if let Some(db) = &mut self.feedback_db {
let _ = db.update_rating(&id, entry.rating, fb_opt.as_deref());
}
self.set_status("Feedback saved".to_string());
}
// Delete button
if ui.button("🗑").on_hover_text("Delete").clicked() {
if let Some(db) = &mut self.feedback_db {
let _ = db.delete(&id);
}
self.feedback_dirty = true;
}
});
});
}
});
}
fn open_feedback_db(&mut self) {
let mut dialog = FileDialog::default();
dialog.select_file();
// Store dialog for next frame processing
self.feedback_dialog = Some(dialog);
}
fn new_feedback_db(&mut self) {
let mut dialog = FileDialog::default();
dialog.save_file();
self.feedback_dialog = Some(dialog);
}
fn refresh_feedback(&mut self) {
if let Some(db) = &self.feedback_db {
self.feedback_entries = db.get_all().unwrap_or_default();
}
}
fn generate_batch(&mut self) {
let names: Vec<String> = self
.batch_input
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if names.is_empty() {
self.set_status("No names entered".to_string());
return;
}
let dir = std::path::Path::new(&self.batch_dir);
let _ = std::fs::create_dir_all(dir);
let mut count = 0;
for name in &names {
let spec = SoundSpec {
name: name.clone(),
duration: 0.3,
sample_rate: 44100,
channels: vec![ChannelSpec::Pulse {
duty: 50,
frequency: soundgen_core::FrequencyAutomation {
start: 200.0,
end: 800.0,
curve: soundgen_core::SweepCurve::Exponential,
},
envelope: Some(soundgen_fmt::EnvelopeSpec {
attack: 0.01,
decay: 0.1,
sustain: 0.0,
release: 0.15,
}),
filter: None,
volume: 0.5,
pan: 0.0,
}],
};
let samples = render_spec(&spec);
let wav_path = dir.join(format!("{}.wav", name));
if write_wav(&wav_path, &samples, 44100).is_ok() {
if let Some(db) = &mut self.feedback_db {
let _ = db.add(name, &spec, Some(&wav_path.display().to_string()));
}
count += 1;
}
}
self.feedback_dirty = true;
self.set_status(format!("Generated {} sounds to DB", count));
}
fn export_dataset(&mut self) {
if let Some(db) = &self.feedback_db {
let path = std::path::Path::new("feedback_dataset.jsonl");
match db.export_jsonl(path, 4) {
Ok(count) => {
self.set_status(format!(
"Exported {} rated examples to {}",
count,
path.display()
));
}
Err(e) => {
self.set_status(format!("Export error: {}", e));
}
}
}
}
// ── Playback ──────────────────────────────────────────
fn play_current(&mut self) {
@@ -910,3 +1245,26 @@ fn downsample_for_display(samples: &[f32]) -> Vec<f32> {
mono
}
}
/// Load a WAV file and return interleaved stereo samples.
fn load_wav(path: &str) -> Result<Vec<f32>, String> {
let mut reader = hound::WavReader::open(path).map_err(|e| format!("open wav: {}", e))?;
let spec = reader.spec();
let samples: Vec<i16> = reader.samples::<i16>().filter_map(|s| s.ok()).collect();
let mut out = Vec::with_capacity(samples.len());
for s in samples {
out.push(s as f32 / 32767.0);
}
if spec.channels == 1 {
let mut stereo = Vec::with_capacity(out.len() * 2);
for s in out {
stereo.push(s);
stereo.push(s);
}
return Ok(stereo);
}
Ok(out)
}