Skip to main content

max / audiofiles

Add auto-tagging, clustering, and folder-tag UI (classifier Phase 4b) The ML half of the tag dashboard, as Settings sections + a detail-panel action. - Settings > Auto-Tagging: "Suggest tags across library" (build the exemplar index and auto-apply confident k-NN tags), Undo, and per-tag review/auto threshold sliders (tag_policy) with an add-tag input. - Settings > Clustering: choose group count, find clusters, play a representative (medoid), name each group to seed a tag, and "Remove all cluster tags" to undo a pass. - Settings > Folder Tags: scan VFS folders into tag candidates, edit the suggested tag, apply per folder, and undo. - Detail panel: "Suggest similar tags" runs k-NN for the selected sample and lists suggestions (score + neighbor count) with one-click Add (source='ml'). - Core: exemplar::auto_apply_library; backend + BrowserState classifier_* helpers for all of the above. Core 512, browser 212 tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 00:29 UTC
Commit: ec0f6d347379677cd3c76d8e761f8def2d5b226d
Parent: 8fed5ff
10 files changed, +643 insertions, -1 deletion
@@ -627,6 +627,78 @@ impl Backend for DirectBackend {
627 627 Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?)
628 628 }
629 629
630 + fn ml_suggest_sample(
631 + &self,
632 + hash: &str,
633 + k: usize,
634 + ) -> BackendResult<audiofiles_core::analysis::exemplar::MlOutcome> {
635 + use audiofiles_core::analysis::exemplar;
636 + let db = self.db.lock();
637 + let index = exemplar::build_index(&db)?;
638 + Ok(exemplar::preview_sample(&db, hash, &index, k)?)
639 + }
640 +
641 + fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> {
642 + let db = self.db.lock();
643 + Ok(audiofiles_core::analysis::exemplar::auto_apply_library(&db, k)?)
644 + }
645 +
646 + fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
647 + let db = self.db.lock();
648 + audiofiles_core::rules::apply_tag_sourced(&db, hash, tag, "ml")?;
649 + Ok(())
650 + }
651 +
652 + fn get_tag_policy(
653 + &self,
654 + tag: &str,
655 + ) -> BackendResult<audiofiles_core::analysis::exemplar::TagPolicy> {
656 + let db = self.db.lock();
657 + Ok(audiofiles_core::analysis::exemplar::get_policy(&db, tag)?)
658 + }
659 +
660 + fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()> {
661 + let db = self.db.lock();
662 + Ok(audiofiles_core::analysis::exemplar::set_policy(&db, tag, review, auto)?)
663 + }
664 +
665 + fn list_tag_policies(
666 + &self,
667 + ) -> BackendResult<Vec<(String, audiofiles_core::analysis::exemplar::TagPolicy)>> {
668 + let db = self.db.lock();
669 + Ok(audiofiles_core::analysis::exemplar::list_policies(&db)?)
670 + }
671 +
672 + fn cluster_library(
673 + &self,
674 + k: usize,
675 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>> {
676 + let db = self.db.lock();
677 + Ok(audiofiles_core::analysis::cluster::cluster_library(&db, k, 50)?.clusters)
678 + }
679 +
680 + fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize> {
681 + let db = self.db.lock();
682 + Ok(audiofiles_core::analysis::cluster::apply_cluster_tag(&db, members, tag)?)
683 + }
684 +
685 + fn harvest_folder_labels(
686 + &self,
687 + ) -> BackendResult<Vec<audiofiles_core::harvest::FolderLabel>> {
688 + let db = self.db.lock();
689 + Ok(audiofiles_core::harvest::harvest_folder_labels(&db)?)
690 + }
691 +
692 + fn apply_harvested_tag(&self, hashes: &[String], tag: &str) -> BackendResult<usize> {
693 + let db = self.db.lock();
694 + Ok(audiofiles_core::harvest::apply_harvested_tag(&db, hashes, tag)?)
695 + }
696 +
697 + fn remove_tags_by_source(&self, source: &str) -> BackendResult<usize> {
698 + let db = self.db.lock();
699 + Ok(audiofiles_core::rules::remove_tags_by_source(&db, source)?)
700 + }
701 +
630 702 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
631 703 let db = self.db.lock();
632 704 Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash))
@@ -387,6 +387,54 @@ pub trait Backend: Send + Sync {
387 387 hash: &str,
388 388 ) -> BackendResult<Vec<(String, String, Option<String>)>>;
389 389
390 + // --- Auto-tagging: k-NN suggestions, thresholds, bootstrap (Layer B) ---
391 +
392 + /// Build the exemplar index and score a sample, without writing anything.
393 + fn ml_suggest_sample(
394 + &self,
395 + hash: &str,
396 + k: usize,
397 + ) -> BackendResult<audiofiles_core::analysis::exemplar::MlOutcome>;
398 +
399 + /// Auto-apply above-threshold tags across the whole library; returns tags applied.
400 + fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize>;
401 +
402 + /// Accept a single ML suggestion: apply the tag with `source = 'ml'`.
403 + fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()>;
404 +
405 + /// A tag's thresholds (defaults if unset).
406 + fn get_tag_policy(
407 + &self,
408 + tag: &str,
409 + ) -> BackendResult<audiofiles_core::analysis::exemplar::TagPolicy>;
410 +
411 + /// Set a tag's review/auto thresholds.
412 + fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()>;
413 +
414 + /// All configured per-tag policies.
415 + fn list_tag_policies(
416 + &self,
417 + ) -> BackendResult<Vec<(String, audiofiles_core::analysis::exemplar::TagPolicy)>>;
418 +
419 + /// Cluster the library's feature vectors into `k` groups (for naming/seeding).
420 + fn cluster_library(
421 + &self,
422 + k: usize,
423 + ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>>;
424 +
425 + /// Apply a tag to every member of a named cluster (`source = 'cluster'`).
426 + fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize>;
427 +
428 + /// Folder-derived tag candidates from the VFS structure.
429 + fn harvest_folder_labels(&self)
430 + -> BackendResult<Vec<audiofiles_core::harvest::FolderLabel>>;
431 +
432 + /// Apply a harvested tag to the given samples (`source = 'harvest'`).
433 + fn apply_harvested_tag(&self, hashes: &[String], tag: &str) -> BackendResult<usize>;
434 +
435 + /// Remove every tag applied by a given machine source (e.g. undo a clustering pass).
436 + fn remove_tags_by_source(&self, source: &str) -> BackendResult<usize>;
437 +
390 438 /// Get waveform display data for a sample.
391 439 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>;
392 440
@@ -179,4 +179,175 @@ impl BrowserState {
179 179 Err(e) => self.status = format!("Could not apply rules: {e}"),
180 180 }
181 181 }
182 +
183 + // ── Layer B: k-NN auto-tagging ──
184 +
185 + /// Auto-apply above-threshold k-NN suggestions across the whole library.
186 + pub fn classifier_auto_apply_ml(&mut self) {
187 + use audiofiles_core::analysis::exemplar::DEFAULT_K;
188 + match self.backend.ml_auto_apply_all(DEFAULT_K) {
189 + Ok(n) => {
190 + self.classifier.last_ml_apply = Some(n);
191 + self.refresh_selected_tags();
192 + self.status = format!("Auto-tagging applied {n} tag{}", if n == 1 { "" } else { "s" });
193 + }
194 + Err(e) => self.status = format!("Auto-tagging failed: {e}"),
195 + }
196 + }
197 +
198 + pub fn ensure_policies_loaded(&mut self) {
199 + if !self.classifier.policies_loaded {
200 + self.refresh_policies();
201 + }
202 + }
203 +
204 + pub fn refresh_policies(&mut self) {
205 + self.classifier.policies = self.backend.list_tag_policies().unwrap_or_default();
206 + self.classifier.policies_loaded = true;
207 + }
208 +
209 + /// Persist a tag's thresholds.
210 + pub fn classifier_set_policy(&mut self, tag: &str, review: f64, auto: f64) {
211 + if let Err(e) = self.backend.set_tag_policy(tag, review, auto) {
212 + self.status = format!("Could not set thresholds: {e}");
213 + }
214 + }
215 +
216 + /// Add a per-tag policy at default thresholds, ready to tune.
217 + pub fn classifier_add_policy(&mut self) {
218 + use audiofiles_core::analysis::exemplar::{DEFAULT_AUTO_THRESHOLD, DEFAULT_REVIEW_THRESHOLD};
219 + let tag = self.classifier.new_policy_tag.trim().to_string();
220 + if tag.is_empty() {
221 + return;
222 + }
223 + if self
224 + .backend
225 + .set_tag_policy(&tag, DEFAULT_REVIEW_THRESHOLD, DEFAULT_AUTO_THRESHOLD)
226 + .is_ok()
227 + {
228 + self.classifier.new_policy_tag.clear();
229 + self.refresh_policies();
230 + }
231 + }
232 +
233 + // ── k-NN suggestions for the selected sample (detail panel) ──
234 +
235 + /// Populate `selected_ml_suggestions` with k-NN suggestions for the selected sample.
236 + pub fn suggest_ml_for_selected(&mut self) {
237 + use audiofiles_core::analysis::exemplar::DEFAULT_K;
238 + self.selected_ml_suggestions.clear();
239 + let Some(hash) = self.selected_node().and_then(|n| n.node.sample_hash.clone()) else {
240 + return;
241 + };
242 + match self.backend.ml_suggest_sample(&hash, DEFAULT_K) {
243 + Ok(outcome) => {
244 + let mut all = outcome.auto_applied;
245 + all.extend(outcome.pending_review);
246 + let existing: std::collections::HashSet<&String> = self.selected_tags.iter().collect();
247 + all.retain(|s| !existing.contains(&s.tag));
248 + if all.is_empty() {
249 + self.status = "No tag suggestions from similar samples.".to_string();
250 + }
251 + self.selected_ml_suggestions = all;
252 + }
253 + Err(e) => self.status = format!("Suggestion failed: {e}"),
254 + }
255 + }
256 +
257 + /// Accept one k-NN suggestion: apply the tag (`source = 'ml'`) and drop it from
258 + /// the pending list, refreshing the chips without clearing other suggestions.
259 + pub fn accept_ml_suggestion(&mut self, tag: &str) {
260 + let Some(hash) = self.selected_node().and_then(|n| n.node.sample_hash.clone()) else {
261 + return;
262 + };
263 + if self.backend.accept_ml_tag(&hash, tag).is_ok() {
264 + self.selected_ml_suggestions.retain(|s| s.tag != tag);
265 + self.status = format!("Added tag \"{tag}\"");
266 + self.selected_tags =
267 + std::sync::Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_default());
268 + if let Ok(prov) = self.backend.sample_tag_provenance(&hash) {
269 + self.selected_tag_sources =
270 + prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect();
271 + }
272 + }
273 + }
274 +
275 + // ── Clustering bootstrap ──
276 +
277 + /// Cluster the library; `cluster_k < 2` means use a default of 8.
278 + pub fn run_clustering(&mut self) {
279 + let k = if self.classifier.cluster_k < 2 { 8 } else { self.classifier.cluster_k as usize };
280 + match self.backend.cluster_library(k) {
281 + Ok(clusters) => {
282 + self.classifier.cluster_names = vec![String::new(); clusters.len()];
283 + self.classifier.clusters = clusters;
284 + self.status = format!("Found {} cluster{}", self.classifier.clusters.len(),
285 + if self.classifier.clusters.len() == 1 { "" } else { "s" });
286 + }
287 + Err(e) => self.status = format!("Clustering failed: {e}"),
288 + }
289 + }
290 +
291 + /// Apply the named tag to every member of cluster `idx`.
292 + pub fn apply_cluster(&mut self, idx: usize) {
293 + let Some(cluster) = self.classifier.clusters.get(idx) else { return };
294 + let members = cluster.member_hashes.clone();
295 + let tag = self.classifier.cluster_names.get(idx).cloned().unwrap_or_default();
296 + let tag = tag.trim().to_string();
297 + if tag.is_empty() {
298 + self.status = "Name the cluster before tagging it.".to_string();
299 + return;
300 + }
301 + match self.backend.apply_cluster_tag(&members, &tag) {
302 + Ok(n) => {
303 + self.status = format!("Tagged {n} sample{} as \"{tag}\"", if n == 1 { "" } else { "s" });
304 + self.refresh_selected_tags();
305 + }
306 + Err(e) => self.status = format!("Could not tag cluster: {e}"),
307 + }
308 + }
309 +
310 + // ── Folder-label harvest ──
311 +
312 + pub fn ensure_folder_loaded(&mut self) {
313 + if !self.classifier.folder_loaded {
314 + self.refresh_folder_labels();
315 + }
316 + }
317 +
318 + pub fn refresh_folder_labels(&mut self) {
319 + let labels = self.backend.harvest_folder_labels().unwrap_or_default();
320 + self.classifier.folder_tags = labels.iter().map(|l| l.suggested_tag.clone()).collect();
321 + self.classifier.folder_labels = labels;
322 + self.classifier.folder_loaded = true;
323 + }
324 +
325 + /// Apply the (possibly re-namespaced) tag to folder label `idx`.
326 + pub fn apply_folder_label(&mut self, idx: usize) {
327 + let Some(label) = self.classifier.folder_labels.get(idx) else { return };
328 + let hashes = label.sample_hashes.clone();
329 + let tag = self.classifier.folder_tags.get(idx).cloned().unwrap_or_default();
330 + let tag = tag.trim().to_string();
331 + if tag.is_empty() {
332 + return;
333 + }
334 + match self.backend.apply_harvested_tag(&hashes, &tag) {
335 + Ok(n) => {
336 + self.status = format!("Tagged {n} sample{} as \"{tag}\"", if n == 1 { "" } else { "s" });
337 + self.refresh_selected_tags();
338 + }
339 + Err(e) => self.status = format!("Could not apply folder tag: {e}"),
340 + }
341 + }
342 +
343 + /// Undo a whole machine-source pass (e.g. remove all `cluster` tags).
344 + pub fn undo_tag_source(&mut self, source: &str) {
345 + match self.backend.remove_tags_by_source(source) {
346 + Ok(n) => {
347 + self.status = format!("Removed {n} {source} tag{}", if n == 1 { "" } else { "s" });
348 + self.refresh_selected_tags();
349 + }
350 + Err(e) => self.status = format!("Could not remove tags: {e}"),
351 + }
352 + }
182 353 }
@@ -103,6 +103,9 @@ pub struct BrowserState {
103 103 /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
104 104 /// absent from this map is manual. Refreshed alongside `selected_tags`.
105 105 pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>,
106 + /// k-NN tag suggestions for the selected sample, populated on demand by the
107 + /// "Suggest similar tags" action and cleared when the selection changes.
108 + pub selected_ml_suggestions: Vec<audiofiles_core::analysis::exemplar::MlSuggestion>,
106 109 pub status: String,
107 110 /// When the current `status` message was posted. Drives the footer's
108 111 /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means
@@ -462,6 +465,7 @@ impl BrowserState {
462 465 selection: Selection::new(),
463 466 selected_tags: Arc::new(Vec::new()),
464 467 selected_tag_sources: std::collections::HashMap::new(),
468 + selected_ml_suggestions: Vec::new(),
465 469 status: String::new(),
466 470 status_set_at: None,
467 471 selected_analysis: None,
@@ -119,6 +119,7 @@ impl BrowserState {
119 119 pub fn refresh_selected_tags(&mut self) {
120 120 self.selected_tags = Arc::new(Vec::new());
121 121 self.selected_tag_sources.clear();
122 + self.selected_ml_suggestions.clear();
122 123 if let Some(node) = self.selected_node()
123 124 && let Some(hash) = &node.node.sample_hash {
124 125 let hash = hash.clone();
@@ -1872,4 +1872,17 @@ mod misc {
1872 1872 assert!(state.classifier.rules.is_empty());
1873 1873 assert!(!state.backend.get_sample_tags("aaa111").unwrap().contains(&"auto.tagged".to_string()));
1874 1874 }
1875 +
1876 + #[test]
1877 + fn classifier_add_policy_persists() {
1878 + let (mut state, _dir) = make_state();
1879 + state.classifier.new_policy_tag = "instrument.drum.kick".to_string();
1880 + state.classifier_add_policy();
1881 + assert!(state.classifier.new_policy_tag.is_empty());
1882 + assert!(state
1883 + .classifier
1884 + .policies
1885 + .iter()
1886 + .any(|(t, _)| t == "instrument.drum.kick"));
1887 + }
1875 1888 }
@@ -290,6 +290,29 @@ pub struct ClassifierUiState {
290 290 pub editing: Option<RuleDraft>,
291 291 /// Number of samples changed by the last "Apply rules now" run.
292 292 pub last_apply: Option<usize>,
293 +
294 + // --- Auto-tagging (Layer B) ---
295 + /// Tags applied by the last library-wide "Suggest tags" run.
296 + pub last_ml_apply: Option<usize>,
297 + /// Per-tag ML thresholds (loaded on demand).
298 + pub policies: Vec<(String, audiofiles_core::analysis::exemplar::TagPolicy)>,
299 + pub policies_loaded: bool,
300 + /// Tag input for adding a new per-tag policy.
301 + pub new_policy_tag: String,
302 +
303 + // --- Clustering bootstrap ---
304 + /// Requested cluster count; 0 = auto (suggest_k).
305 + pub cluster_k: u32,
306 + /// Last clustering result.
307 + pub clusters: Vec<audiofiles_core::analysis::cluster::Cluster>,
308 + /// Editable name (tag) per cluster, parallel to `clusters`.
309 + pub cluster_names: Vec<String>,
310 +
311 + // --- Folder-label harvest ---
312 + pub folder_labels: Vec<audiofiles_core::harvest::FolderLabel>,
313 + /// Editable tag per folder label, parallel to `folder_labels`.
314 + pub folder_tags: Vec<String>,
315 + pub folder_loaded: bool,
293 316 }
294 317
295 318 /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule`
@@ -135,8 +135,19 @@ fn op_needs_value(op: RuleOp) -> bool {
135 135 !matches!(op, RuleOp::Exists | RuleOp::NotExists | RuleOp::IsTrue | RuleOp::IsFalse)
136 136 }
137 137
138 - /// Draw the "Tag Rules" Settings section.
138 + /// Draw all tag-classifier sections in Settings.
139 139 pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
140 + draw_rules_header(ui, state);
141 + ui.add_space(theme::space::SM);
142 + draw_autotag_header(ui, state);
143 + ui.add_space(theme::space::SM);
144 + draw_cluster_header(ui, state);
145 + ui.add_space(theme::space::SM);
146 + draw_folder_header(ui, state);
147 + }
148 +
149 + /// "Tag Rules" section (Layer A).
150 + fn draw_rules_header(ui: &mut egui::Ui, state: &mut BrowserState) {
140 151 egui::CollapsingHeader::new(egui::RichText::new("Tag Rules").strong())
141 152 .default_open(false)
142 153 .show(ui, |ui| {
@@ -420,3 +431,230 @@ fn draw_editor(ui: &mut egui::Ui, state: &mut BrowserState) {
420 431 state.classifier_cancel_draft();
421 432 }
422 433 }
434 +
435 + /// "Auto-Tagging" section (Layer B): library-wide suggest + per-tag thresholds.
436 + fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) {
437 + egui::CollapsingHeader::new(egui::RichText::new("Auto-Tagging").strong())
438 + .default_open(false)
439 + .show(ui, |ui| {
440 + ui.label(
441 + egui::RichText::new(
442 + "Suggests tags from samples that look like ones you've already tagged. \
443 + Tags above a per-tag threshold are applied automatically.",
444 + )
445 + .small()
446 + .color(theme::content_muted()),
447 + );
448 + ui.add_space(theme::space::SM);
449 + ui.horizontal(|ui| {
450 + if ui
451 + .button("Suggest tags across library")
452 + .on_hover_text("Build the model from your tagged samples and auto-apply confident tags")
453 + .clicked()
454 + {
455 + state.classifier_auto_apply_ml();
456 + }
457 + if ui
458 + .button("Undo")
459 + .on_hover_text("Remove every tag applied by auto-tagging")
460 + .clicked()
461 + {
462 + state.undo_tag_source("ml");
463 + }
464 + });
465 + if let Some(n) = state.classifier.last_ml_apply {
466 + ui.label(
467 + egui::RichText::new(format!("Last run: {n} tag{} applied", if n == 1 { "" } else { "s" }))
468 + .small()
469 + .color(theme::content_muted()),
470 + );
471 + }
472 +
473 + ui.add_space(theme::space::MD);
474 + widgets::subsection_label(ui, "Per-tag thresholds");
475 + ui.label(
476 + egui::RichText::new("review = surface for review \u{00b7} auto = apply automatically")
477 + .small()
478 + .color(theme::content_muted()),
479 + );
480 + state.ensure_policies_loaded();
481 +
482 + let policies = state.classifier.policies.clone();
483 + let mut changed: Option<(String, f64, f64)> = None;
484 + for (tag, policy) in &policies {
485 + let mut review = policy.review_threshold as f32;
486 + let mut auto = policy.auto_threshold as f32;
487 + ui.horizontal(|ui| {
488 + ui.label(egui::RichText::new(tag).small());
489 + });
490 + ui.horizontal(|ui| {
491 + ui.label(egui::RichText::new("review").small().color(theme::content_muted()));
492 + let r = ui.add(egui::Slider::new(&mut review, 0.0..=1.0).show_value(true));
493 + ui.label(egui::RichText::new("auto").small().color(theme::content_muted()));
494 + let a = ui.add(egui::Slider::new(&mut auto, 0.0..=1.0).show_value(true));
495 + if r.drag_stopped() || a.drag_stopped()
496 + || (r.changed() && !r.dragged())
497 + || (a.changed() && !a.dragged())
498 + {
499 + changed = Some((tag.clone(), review as f64, auto as f64));
500 + }
501 + });
502 + }
503 + if let Some((tag, review, auto)) = changed {
504 + state.classifier_set_policy(&tag, review, auto);
505 + state.refresh_policies();
506 + }
507 +
508 + ui.add_space(theme::space::SM);
509 + ui.horizontal(|ui| {
510 + ui.add(
511 + egui::TextEdit::singleline(&mut state.classifier.new_policy_tag)
512 + .desired_width(170.0)
513 + .hint_text("tag to configure"),
514 + );
515 + if ui.button("Add").clicked() {
516 + state.classifier_add_policy();
517 + }
518 + });
519 + });
520 + }
521 +
522 + /// "Clustering" section: unsupervised cold-start grouping.
523 + fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) {
524 + egui::CollapsingHeader::new(egui::RichText::new("Clustering").strong())
525 + .default_open(false)
526 + .show(ui, |ui| {
527 + ui.label(
528 + egui::RichText::new(
529 + "Groups similar samples so you can name them and seed your first tags \
530 + \u{2014} useful when nothing is tagged yet.",
531 + )
532 + .small()
533 + .color(theme::content_muted()),
534 + );
535 + ui.add_space(theme::space::SM);
536 + ui.horizontal(|ui| {
537 + let mut k = state.classifier.cluster_k.max(2);
538 + ui.label("Groups");
539 + if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() {
540 + state.classifier.cluster_k = k;
541 + }
542 + if ui.button("Find clusters").clicked() {
543 + state.classifier.cluster_k = k;
544 + state.run_clustering();
545 + }
546 + });
547 +
548 + if state.classifier.clusters.is_empty() {
549 + return;
550 + }
551 + ui.add_space(theme::space::SM);
552 +
553 + let clusters = state.classifier.clusters.clone();
554 + let mut play: Option<String> = None;
555 + let mut apply: Option<usize> = None;
556 + for (i, cluster) in clusters.iter().enumerate() {
557 + ui.horizontal(|ui| {
558 + ui.label(
559 + egui::RichText::new(format!("{} samples", cluster.member_hashes.len()))
560 + .small()
561 + .color(theme::content_secondary()),
562 + );
563 + if ui.small_button("Play").on_hover_text("Preview a representative sample").clicked() {
564 + play = Some(cluster.medoid_hash.clone());
565 + }
566 + if let Some(name) = state.classifier.cluster_names.get_mut(i) {
567 + ui.add(
568 + egui::TextEdit::singleline(name)
569 + .desired_width(150.0)
570 + .hint_text("tag for this group"),
571 + );
572 + }
573 + if ui.small_button("Tag").clicked() {
574 + apply = Some(i);
575 + }
576 + });
577 + }
578 + if let Some(hash) = play {
579 + state.trigger_preview(&hash);
580 + }
581 + if let Some(i) = apply {
582 + state.apply_cluster(i);
583 + }
584 +
585 + ui.add_space(theme::space::SM);
586 + if ui
587 + .button("Remove all cluster tags")
588 + .on_hover_text("Undo every tag applied from clustering")
589 + .clicked()
590 + {
591 + state.undo_tag_source("cluster");
592 + }
593 + });
594 + }
595 +
596 + /// "Folder Tags" section: derive tags from the library's folder structure.
597 + fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) {
598 + egui::CollapsingHeader::new(egui::RichText::new("Folder Tags").strong())
599 + .default_open(false)
600 + .show(ui, |ui| {
601 + ui.label(
602 + egui::RichText::new(
603 + "Turns folders that directly contain samples into tags \u{2014} useful when \
604 + your library is already organized into folders.",
605 + )
606 + .small()
607 + .color(theme::content_muted()),
608 + );
609 + ui.add_space(theme::space::SM);
610 + ui.horizontal(|ui| {
611 + if ui.button("Scan folders").clicked() {
612 + state.refresh_folder_labels();
613 + }
614 + if ui
615 + .button("Undo")
616 + .on_hover_text("Remove every tag applied from folders")
617 + .clicked()
618 + {
619 + state.undo_tag_source("harvest");
620 + }
621 + });
622 +
623 + if !state.classifier.folder_loaded {
624 + return;
625 + }
626 + if state.classifier.folder_labels.is_empty() {
627 + ui.label(
628 + egui::RichText::new("No folders with samples found.")
629 + .small()
630 + .color(theme::content_muted()),
631 + );
632 + return;
633 + }
634 + ui.add_space(theme::space::SM);
635 +
636 + let labels = state.classifier.folder_labels.clone();
637 + let mut apply: Option<usize> = None;
638 + for (i, label) in labels.iter().enumerate() {
639 + ui.horizontal(|ui| {
640 + ui.label(egui::RichText::new(&label.folder_name).small());
641 + ui.label(
642 + egui::RichText::new(format!("({})", label.sample_hashes.len()))
643 + .small()
644 + .color(theme::content_muted()),
645 + );
646 + });
647 + ui.horizontal(|ui| {
648 + if let Some(tag) = state.classifier.folder_tags.get_mut(i) {
649 + ui.add(egui::TextEdit::singleline(tag).desired_width(180.0));
650 + }
651 + if ui.small_button("Apply").clicked() {
652 + apply = Some(i);
653 + }
654 + });
655 + }
656 + if let Some(i) = apply {
657 + state.apply_folder_label(i);
658 + }
659 + });
660 + }
@@ -265,6 +265,38 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
265 265 });
266 266 }
267 267
268 + // k-NN tag suggestions from acoustically similar samples (on demand, so the
269 + // exemplar index is only built when the user asks).
270 + ui.add_space(theme::space::SM);
271 + if ui
272 + .button("Suggest similar tags")
273 + .on_hover_text("Find tags from samples that sound similar to this one")
274 + .clicked()
275 + {
276 + state.suggest_ml_for_selected();
277 + }
278 + if !state.selected_ml_suggestions.is_empty() {
279 + let suggestions = state.selected_ml_suggestions.clone();
280 + let mut accept: Option<String> = None;
281 + for s in &suggestions {
282 + ui.horizontal(|ui| {
283 + if ui.small_button("Add").clicked() {
284 + accept = Some(s.tag.clone());
285 + }
286 + ui.label(egui::RichText::new(&s.tag).small())
287 + .on_hover_text(format!("{} similar sample(s) carry this tag", s.neighbors.len()));
288 + ui.label(
289 + egui::RichText::new(format!("{:.0}%", s.score * 100.0))
290 + .small()
291 + .color(theme::content_muted()),
292 + );
293 + });
294 + }
295 + if let Some(tag) = accept {
296 + state.accept_ml_suggestion(&tag);
297 + }
298 + }
299 +
268 300 // Tag suggestions based on classification. Per-classification dismissals
269 301 // let the user say "I never tag kicks with `percussion`" once and have
270 302 // the suggestion stop appearing on every future kick.
@@ -391,6 +391,31 @@ pub fn apply_ml_suggestions(
391 391 Ok(outcome)
392 392 }
393 393
394 + /// Build the index once and auto-apply above-threshold tags to every analyzed,
395 + /// non-deleted sample. Returns the total number of tags applied. Heavy — intended
396 + /// for an explicit "suggest across library" action.
397 + #[instrument(skip_all)]
398 + pub fn auto_apply_library(db: &Database, k: usize) -> Result<usize> {
399 + let index = build_index(db)?;
400 + if index.is_empty() {
401 + return Ok(0);
402 + }
403 + let hashes: Vec<String> = {
404 + let mut stmt = db.conn().prepare(
405 + "SELECT s.hash FROM samples s
406 + JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1
407 + WHERE s.deleted_at IS NULL",
408 + )?;
409 + stmt.query_map([FEATURE_VERSION], |row| row.get(0))?
410 + .collect::<std::result::Result<Vec<_>, _>>()?
411 + };
412 + let mut applied = 0;
413 + for hash in hashes {
414 + applied += apply_ml_suggestions(db, &hash, &index, k)?.auto_applied.len();
415 + }
416 + Ok(applied)
417 + }
418 +
394 419 #[cfg(test)]
395 420 mod tests {
396 421 use super::*;
@@ -476,6 +501,21 @@ mod tests {
476 501 }
477 502
478 503 #[test]
504 + fn auto_apply_library_tags_unlabeled() {
505 + let db = Database::open_in_memory().unwrap();
506 + insert(&db, "k1", vec_at(0.0), &["instrument.drum.kick"]);
507 + insert(&db, "k2", vec_at(0.05), &["instrument.drum.kick"]);
508 + insert(&db, "k3", vec_at(-0.05), &["instrument.drum.kick"]);
509 + insert(&db, "q", vec_at(0.02), &[]); // unlabeled, near the kicks
510 +
511 + let applied = auto_apply_library(&db, DEFAULT_K).unwrap();
512 + assert!(applied >= 1, "expected at least one auto-applied tag");
513 + assert!(crate::tags::get_sample_tags(&db, "q").unwrap().contains(&"instrument.drum.kick".to_string()));
514 + // Exemplars keep their own single tag (not re-suggested to themselves).
515 + assert_eq!(crate::tags::get_sample_tags(&db, "k1").unwrap().len(), 1);
516 + }
517 +
518 + #[test]
479 519 fn review_band_between_thresholds() {
480 520 let db = Database::open_in_memory().unwrap();
481 521 // Two equidistant neighbors with different tags -> each ~0.5 score.