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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user