Skip to main content

max / audiofiles

Triage UX audit of classifier Settings sections Critical: run the heavy library-wide classifier ops (train, suggest-across- library, find-clusters, export) off the GUI thread via a new classifier_worker (one-shot thread + own WAL connection, mirrors cleanup.rs) dispatched through Backend::start_classifier_job and drained in poll_workers. Trigger buttons disable and a spinner/progress line shows while busy; the editor repaints while a job runs. Supersedes Phase 6's synchronous-training note. Major: surface results inline in-section (footer status is hidden by the Settings modal scrim); make layer Remove a danger button with a two-step inline confirm; truncate long layer names with a tooltip. Minor/polish: primary_button for Train/Export/Import, dim disabled layers, fixed-width Train button, weight-slider explainer, disable Export when there's nothing to share. ClassifierJob/ClassifierJobResult added to the Backend; Cluster and TrainedHeadInfo gain serde derives (carried in BackendEvent). Workspace 907 tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 02:29 UTC
Commit: 773dc9645d833e607aa2f877eb7bbbe98d203065
Parent: a264ff1
11 files changed, +506 insertions, -81 deletions
@@ -65,6 +65,7 @@ pub struct DirectBackend {
65 65 cleanup_worker: Mutex<Option<CleanupHandle>>,
66 66 edit_worker: Mutex<Option<EditWorkerHandle>>,
67 67 forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>,
68 + classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>,
68 69 // Import context for the in-flight forge op, applied when its CPU stage
69 70 // completes (the worker produces temp files; the GUI thread imports them).
70 71 forge_pending: Mutex<Option<ForgePending>>,
@@ -89,6 +90,7 @@ impl DirectBackend {
89 90 cleanup_worker: Mutex::new(None),
90 91 edit_worker: Mutex::new(None),
91 92 forge_worker: Mutex::new(None),
93 + classifier_worker: Mutex::new(None),
92 94 forge_pending: Mutex::new(None),
93 95 fingerprint_index: Mutex::new(None),
94 96 similarity_index: Mutex::new(None),
@@ -681,6 +683,16 @@ impl Backend for DirectBackend {
681 683 Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS))
682 684 }
683 685
686 + fn start_classifier_job(&self, job: super::ClassifierJob) -> BackendResult<()> {
687 + let db_path = self.data_dir.join("audiofiles.db");
688 + // Drop (join) any prior job before starting a new one — the UI gates to one at a time.
689 + *self.classifier_worker.lock() = None;
690 + let handle = crate::classifier_worker::spawn_classifier_job(db_path, job)
691 + .map_err(|e| BackendError::Other(format!("failed to spawn classifier worker: {e}")))?;
692 + *self.classifier_worker.lock() = Some(handle);
693 + Ok(())
694 + }
695 +
684 696 fn export_afcl(
685 697 &self,
686 698 path: &Path,
@@ -1675,6 +1687,25 @@ impl Backend for DirectBackend {
1675 1687 }
1676 1688 }
1677 1689
1690 + // Poll classifier worker (one-shot job).
1691 + let job_done = {
1692 + let guard = self.classifier_worker.lock();
1693 + match guard.as_ref().and_then(|w| w.try_recv()) {
1694 + Some(crate::classifier_worker::ClassifierWorkerEvent::Done(result)) => {
1695 + events.push(BackendEvent::ClassifierJobDone(result));
1696 + true
1697 + }
1698 + Some(crate::classifier_worker::ClassifierWorkerEvent::Failed(error)) => {
1699 + events.push(BackendEvent::ClassifierJobFailed(error));
1700 + true
1701 + }
1702 + None => false,
1703 + }
1704 + };
1705 + if job_done {
1706 + *self.classifier_worker.lock() = None; // join the finished thread
1707 + }
1708 +
1678 1709 events
1679 1710 }
1680 1711 }
@@ -137,6 +137,10 @@ pub enum BackendEvent {
137 137 ForgeError {
138 138 error: String,
139 139 },
140 +
141 + // Classifier events (train / auto-tag / cluster / export run on a worker thread)
142 + ClassifierJobDone(ClassifierJobResult),
143 + ClassifierJobFailed(String),
140 144 }
141 145
142 146 /// Serializable summary of a conform overshoot, for the completion event.
@@ -186,7 +190,7 @@ pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = "forge.auto_trim_overshoot";
186 190
187 191 /// Lightweight summary of a persisted trained head for the Settings UI (the model's
188 192 /// weights stay in core).
189 - #[derive(Debug, Clone, Copy)]
193 + #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
190 194 pub struct TrainedHeadInfo {
191 195 /// Number of tags the model can score.
192 196 pub class_count: usize,
@@ -196,6 +200,30 @@ pub struct TrainedHeadInfo {
196 200 pub trained_at: i64,
197 201 }
198 202
203 + /// A library-wide classifier operation heavy enough to run off the GUI thread (see
204 + /// [`Backend::start_classifier_job`]). The completion arrives as a
205 + /// [`BackendEvent::ClassifierJobDone`] / [`BackendEvent::ClassifierJobFailed`].
206 + #[derive(Debug, Clone)]
207 + pub enum ClassifierJob {
208 + /// Distil the tagged library into a trained head.
209 + TrainHead,
210 + /// Auto-apply above-threshold tags across the library (head-routed or k-NN).
211 + AutoApply { k: usize },
212 + /// Cluster the library into `k` groups.
213 + Cluster { k: usize },
214 + /// Export the user's classifier data to a `.afcl` file.
215 + Export { path: std::path::PathBuf, opts: audiofiles_core::analysis::afcl::ExportOptions },
216 + }
217 +
218 + /// The result of a completed [`ClassifierJob`].
219 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
220 + pub enum ClassifierJobResult {
221 + Trained(Option<TrainedHeadInfo>),
222 + AutoApplied(usize),
223 + Clustered(Vec<audiofiles_core::analysis::cluster::Cluster>),
224 + Exported(std::path::PathBuf),
225 + }
226 +
199 227 /// The core abstraction separating UI from data access.
200 228 ///
201 229 /// Every method is synchronous and blocking. The trait is `Send + Sync` so it
@@ -444,6 +472,11 @@ pub trait Backend: Send + Sync {
444 472 /// advisory hint in the UI.
445 473 fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)>;
446 474
475 + /// Run a heavy classifier op on a worker thread; completion arrives via `poll_events`
476 + /// as [`BackendEvent::ClassifierJobDone`] / [`BackendEvent::ClassifierJobFailed`].
477 + /// Keeps the GUI responsive on large libraries.
478 + fn start_classifier_job(&self, job: ClassifierJob) -> BackendResult<()>;
479 +
447 480 // --- .afcl file sharing (classifier layers) ---
448 481
449 482 /// Write the user's selected classifier data to a `.afcl` file.
@@ -0,0 +1,187 @@
1 + //! Background classifier worker: runs heavy library-wide classifier operations off the
2 + //! GUI thread so the window stays responsive (training, library auto-tagging, clustering,
3 + //! and `.afcl` export can each take seconds-to-minutes on a large library).
4 + //!
5 + //! Mirrors `cleanup.rs`: a one-shot thread with its own `Database` connection (WAL +
6 + //! `busy_timeout`, so it runs concurrently with the GUI connection), reporting back over a
7 + //! channel the GUI polls each frame. One job runs at a time — the UI disables the trigger
8 + //! buttons while a job is in flight, and dispatching a new job drops (joins) the prior one.
9 +
10 + use std::path::PathBuf;
11 + use std::sync::{mpsc, Mutex};
12 + use std::thread;
13 +
14 + use tracing::{error, instrument};
15 +
16 + use audiofiles_core::analysis::{afcl, cluster, exemplar, trained_head};
17 + use audiofiles_core::db::Database;
18 +
19 + use crate::backend::{ClassifierJob, ClassifierJobResult, TrainedHeadInfo};
20 +
21 + /// Event sent from the classifier worker back to the GUI thread.
22 + pub enum ClassifierWorkerEvent {
23 + Done(ClassifierJobResult),
24 + Failed(String),
25 + }
26 +
27 + /// Handle for a single in-flight classifier job. The receiver is wrapped in a `Mutex` so
28 + /// `BrowserState` stays `Sync` (required by nih-plug); only the GUI thread calls `try_recv`.
29 + pub struct ClassifierHandle {
30 + event_rx: Mutex<mpsc::Receiver<ClassifierWorkerEvent>>,
31 + _thread: Option<thread::JoinHandle<()>>,
32 + }
33 +
34 + impl ClassifierHandle {
35 + /// Poll for the job's result without blocking.
36 + pub fn try_recv(&self) -> Option<ClassifierWorkerEvent> {
37 + self.event_rx.lock().ok()?.try_recv().ok()
38 + }
39 + }
40 +
41 + impl Drop for ClassifierHandle {
42 + fn drop(&mut self) {
43 + // The worker is one-shot and detached; join so a replaced job finishes cleanly.
44 + if let Some(handle) = self._thread.take() {
45 + let _ = handle.join();
46 + }
47 + }
48 + }
49 +
50 + /// Spawn a one-shot worker thread to run `job` against the database at `db_path`.
51 + #[instrument(skip_all)]
52 + pub fn spawn_classifier_job(db_path: PathBuf, job: ClassifierJob) -> std::io::Result<ClassifierHandle> {
53 + let (event_tx, event_rx) = mpsc::channel::<ClassifierWorkerEvent>();
54 +
55 + let thread = thread::Builder::new()
56 + .name("classifier-job".to_string())
57 + .spawn(move || {
58 + let event = match Database::open(&db_path) {
59 + Ok(db) => run_job(&db, job),
60 + Err(e) => {
61 + error!("Classifier worker failed to open database: {e}");
62 + ClassifierWorkerEvent::Failed(format!("could not open database: {e}"))
63 + }
64 + };
65 + let _ = event_tx.send(event);
66 + })?;
67 +
68 + Ok(ClassifierHandle { event_rx: Mutex::new(event_rx), _thread: Some(thread) })
69 + }
70 +
71 + fn run_job(db: &Database, job: ClassifierJob) -> ClassifierWorkerEvent {
72 + let result = match job {
73 + ClassifierJob::TrainHead => trained_head::train_and_save(db)
74 + .map(|h| ClassifierJobResult::Trained(h.map(head_info))),
75 + ClassifierJob::AutoApply { k } => auto_apply(db, k).map(ClassifierJobResult::AutoApplied),
76 + ClassifierJob::Cluster { k } => cluster::cluster_library(db, k, 50)
77 + .map(|r| ClassifierJobResult::Clustered(r.clusters)),
78 + ClassifierJob::Export { path, opts } => {
79 + afcl::export_to_path(db, &path, &opts).map(|()| ClassifierJobResult::Exported(path))
80 + }
81 + };
82 + match result {
83 + Ok(r) => ClassifierWorkerEvent::Done(r),
84 + Err(e) => ClassifierWorkerEvent::Failed(e.to_string()),
85 + }
86 + }
87 +
88 + /// Library auto-tagging: route through the trained head when one is current (O(tags) per
89 + /// sample), else brute-force k-NN. Mirrors `DirectBackend::ml_auto_apply_all`.
90 + fn auto_apply(db: &Database, k: usize) -> audiofiles_core::error::Result<usize> {
91 + if let Some(head) = trained_head::load(db)? {
92 + trained_head::auto_apply_library(db, &head)
93 + } else {
94 + exemplar::auto_apply_library(db, k)
95 + }
96 + }
97 +
98 + fn head_info(h: trained_head::TrainedHead) -> TrainedHeadInfo {
99 + TrainedHeadInfo {
100 + class_count: h.classes.len(),
101 + exemplar_count: h.exemplar_count,
102 + trained_at: h.trained_at,
103 + }
104 + }
105 +
106 + #[cfg(test)]
107 + mod tests {
108 + use super::*;
109 + use audiofiles_core::analysis::classify::{FEATURE_VERSION, NUM_FEATURES};
110 +
111 + fn seed(db_path: &std::path::Path) {
112 + let db = Database::open(db_path).unwrap();
113 + for i in 0..6 {
114 + let hash = format!("k{i}");
115 + db.conn()
116 + .execute(
117 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
118 + VALUES (?1, ?1, 'wav', 1, 0, 0)",
119 + [&hash],
120 + )
121 + .unwrap();
122 + let v = serde_json::to_string(&vec![0.01 * i as f64; NUM_FEATURES]).unwrap();
123 + db.conn()
124 + .execute(
125 + "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
126 + rusqlite::params![hash, FEATURE_VERSION, v],
127 + )
128 + .unwrap();
129 + audiofiles_core::tags::add_tag(&db, &hash, "instrument.drum.kick").unwrap();
130 + }
131 + }
132 +
133 + fn run_to_completion(db_path: PathBuf, job: ClassifierJob) -> ClassifierWorkerEvent {
134 + let handle = spawn_classifier_job(db_path, job).unwrap();
135 + loop {
136 + if let Some(ev) = handle.try_recv() {
137 + return ev;
138 + }
139 + std::thread::sleep(std::time::Duration::from_millis(5));
140 + }
141 + }
142 +
143 + #[test]
144 + fn train_job_completes_on_worker() {
145 + let dir = tempfile::TempDir::new().unwrap();
146 + let db_path = dir.path().join("audiofiles.db");
147 + seed(&db_path);
148 + match run_to_completion(db_path, ClassifierJob::TrainHead) {
149 + ClassifierWorkerEvent::Done(ClassifierJobResult::Trained(Some(info))) => {
150 + assert_eq!(info.class_count, 1);
151 + }
152 + _ => panic!("expected a trained head"),
153 + }
154 + }
155 +
156 + #[test]
157 + fn cluster_job_completes_on_worker() {
158 + let dir = tempfile::TempDir::new().unwrap();
159 + let db_path = dir.path().join("audiofiles.db");
160 + seed(&db_path);
161 + match run_to_completion(db_path, ClassifierJob::Cluster { k: 2 }) {
162 + ClassifierWorkerEvent::Done(ClassifierJobResult::Clustered(clusters)) => {
163 + assert!(!clusters.is_empty());
164 + }
165 + _ => panic!("expected clusters"),
166 + }
167 + }
168 +
169 + #[test]
170 + fn export_job_writes_file_on_worker() {
171 + let dir = tempfile::TempDir::new().unwrap();
172 + let db_path = dir.path().join("audiofiles.db");
173 + seed(&db_path);
174 + let out = dir.path().join("out.afcl");
175 + let job = ClassifierJob::Export {
176 + path: out.clone(),
177 + opts: afcl::ExportOptions::default(),
178 + };
179 + match run_to_completion(db_path, job) {
180 + ClassifierWorkerEvent::Done(ClassifierJobResult::Exported(p)) => {
181 + assert_eq!(p, out);
182 + assert!(out.exists());
183 + }
184 + _ => panic!("expected export"),
185 + }
186 + }
187 + }
@@ -24,6 +24,11 @@ pub fn draw_browser(
24 24 if state.poll_workers() {
25 25 ctx.request_repaint();
26 26 }
27 + // Keep polling while a background classifier job runs (no input would
28 + // otherwise repaint, so its completion event would sit unread).
29 + if state.classifier.busy.is_some() {
30 + ctx.request_repaint();
31 + }
27 32 handle_keyboard(ctx, state);
28 33 draw_normal_browser(ui, state, sync_manager);
29 34 }
@@ -1,6 +1,7 @@
1 1 //! Browser UI for audiofiles — file browsing, import, preview, and waveform display.
2 2
3 3 pub mod backend;
4 + pub mod classifier_worker;
4 5 pub mod cleanup;
5 6 pub mod editor;
6 7 pub mod error;
@@ -182,16 +182,71 @@ impl BrowserState {
182 182
183 183 // ── Layer B: k-NN auto-tagging ──
184 184
185 - /// Auto-apply above-threshold k-NN suggestions across the whole library.
185 + /// Auto-apply above-threshold suggestions across the whole library — runs on a worker
186 + /// thread (O(N) k-NN over the library is heavy), completing via `poll_workers`.
186 187 pub fn classifier_auto_apply_ml(&mut self) {
187 188 use audiofiles_core::analysis::exemplar::DEFAULT_K;
188 - match self.backend.ml_auto_apply_all(DEFAULT_K) {
189 - Ok(n) => {
189 + self.start_classifier_job(
190 + crate::backend::ClassifierJob::AutoApply { k: DEFAULT_K },
191 + "Auto-tagging library\u{2026}",
192 + );
193 + }
194 +
195 + /// Apply a finished classifier job's result (called from `poll_workers`). The worker
196 + /// wrote to its own DB connection, so refresh the affected cached views here.
197 + pub fn on_classifier_job_done(&mut self, result: crate::backend::ClassifierJobResult) {
198 + use crate::backend::ClassifierJobResult as R;
199 + match result {
200 + R::Trained(info) => {
201 + self.classifier.head_info = info;
202 + self.refresh_head();
203 + self.status = match info {
204 + Some(i) => format!(
205 + "Trained model on {} sample{} across {} tag{}.",
206 + i.exemplar_count, if i.exemplar_count == 1 { "" } else { "s" },
207 + i.class_count, if i.class_count == 1 { "" } else { "s" },
208 + ),
209 + None => "Not enough tagged samples to train a model yet.".to_string(),
210 + };
211 + }
212 + R::AutoApplied(n) => {
190 213 self.classifier.last_ml_apply = Some(n);
191 214 self.refresh_selected_tags();
192 - self.status = format!("Auto-tagging applied {n} tag{}", if n == 1 { "" } else { "s" });
215 + self.status = format!("Auto-tagging applied {n} tag{}.", if n == 1 { "" } else { "s" });
216 + }
217 + R::Clustered(clusters) => {
218 + self.classifier.cluster_names = vec![String::new(); clusters.len()];
219 + self.classifier.clusters = clusters;
220 + self.status = format!(
221 + "Found {} cluster{}.",
222 + self.classifier.clusters.len(),
223 + if self.classifier.clusters.len() == 1 { "" } else { "s" },
224 + );
225 + }
226 + R::Exported(path) => {
227 + let msg = format!("Exported classifier to {}", path.display());
228 + self.classifier.last_export_msg = Some(msg.clone());
229 + self.status = msg;
230 + }
231 + }
232 + }
233 +
234 + /// Dispatch a heavy classifier job to the worker, marking the section busy. Buttons are
235 + /// disabled while `busy` is set; the result lands in `poll_workers`.
236 + fn start_classifier_job(&mut self, job: crate::backend::ClassifierJob, label: &str) {
237 + if self.classifier.busy.is_some() {
238 + return; // one at a time
239 + }
240 + self.classifier.last_error = None;
241 + match self.backend.start_classifier_job(job) {
242 + Ok(()) => {
243 + self.classifier.busy = Some(label.to_string());
244 + self.status = label.to_string();
245 + }
246 + Err(e) => {
247 + self.classifier.last_error = Some(format!("Could not start: {e}"));
248 + self.status = format!("Could not start: {e}");
193 249 }
194 - Err(e) => self.status = format!("Auto-tagging failed: {e}"),
195 250 }
196 251 }
197 252
@@ -216,7 +271,8 @@ impl BrowserState {
216 271 self.classifier.layers_loaded = true;
217 272 }
218 273
219 - /// Export the user's classifier data to `path` using the export-form selections.
274 + /// Export the user's classifier data to `path` using the export-form selections —
275 + /// runs on a worker thread (reads + serializes the whole library).
220 276 pub fn classifier_export_afcl(&mut self, path: &std::path::Path) {
221 277 use audiofiles_core::analysis::afcl::ExportOptions;
222 278 let opts = ExportOptions {
@@ -230,29 +286,36 @@ impl BrowserState {
230 286 include_rules: self.classifier.export_include_rules,
231 287 include_policy: self.classifier.export_include_policy,
232 288 };
233 - match self.backend.export_afcl(path, &opts) {
234 - Ok(()) => self.status = format!("Exported classifier to {}", path.display()),
235 - Err(e) => self.status = format!("Export failed: {e}"),
236 - }
289 + self.classifier.last_export_msg = None;
290 + self.start_classifier_job(
291 + crate::backend::ClassifierJob::Export { path: path.to_path_buf(), opts },
292 + "Exporting\u{2026}",
293 + );
237 294 }
238 295
239 296 /// Import a `.afcl` file as a new removable layer, refreshing dependent views.
240 297 pub fn classifier_import_afcl(&mut self, path: &std::path::Path) {
241 298 match self.backend.import_afcl(path) {
242 299 Ok(s) => {
243 - self.status = format!(
244 - "Imported \"{}\" — {} exemplar{}, {} rule{} (disabled), {} threshold{}",
300 + let msg = format!(
301 + "Imported \"{}\": {} exemplar{}, {} rule{} (disabled), {} threshold{}.",
245 302 s.name,
246 303 s.exemplars, if s.exemplars == 1 { "" } else { "s" },
247 304 s.rules, if s.rules == 1 { "" } else { "s" },
248 305 s.policies, if s.policies == 1 { "" } else { "s" },
249 306 );
307 + self.status = msg.clone();
308 + self.classifier.last_import_msg = Some(msg);
250 309 // Import adds disabled rules + policies; refresh those views too.
251 310 self.refresh_layers();
252 311 self.refresh_rules();
253 312 self.refresh_policies();
254 313 }
255 - Err(e) => self.status = format!("Import failed: {e}"),
314 + Err(e) => {
315 + let msg = format!("Import failed: {e}");
316 + self.status = msg.clone();
317 + self.classifier.last_import_msg = Some(msg);
318 + }
256 319 }
257 320 }
258 321
@@ -297,26 +360,10 @@ impl BrowserState {
297 360 self.classifier.head_loaded = true;
298 361 }
299 362
300 - /// Distill the tagged library into a trained head and persist it. Once present, the
301 - /// library-wide "Suggest tags" pass routes through it instead of brute-force k-NN.
363 + /// Distill the tagged library into a trained head and persist it — runs on a worker
364 + /// thread. Once present, the library-wide "Suggest tags" pass routes through it.
302 365 pub fn classifier_train_head(&mut self) {
303 - match self.backend.train_classifier_head() {
304 - Ok(Some(info)) => {
305 - self.status = format!(
306 - "Trained model on {} sample{} across {} tag{}",
307 - info.exemplar_count,
308 - if info.exemplar_count == 1 { "" } else { "s" },
309 - info.class_count,
310 - if info.class_count == 1 { "" } else { "s" },
311 - );
312 - }
313 - Ok(None) => {
314 - self.status =
315 - "Not enough tagged samples to train a model yet — tag more first.".to_string();
316 - }
317 - Err(e) => self.status = format!("Training failed: {e}"),
318 - }
319 - self.refresh_head();
366 + self.start_classifier_job(crate::backend::ClassifierJob::TrainHead, "Training model\u{2026}");
320 367 }
321 368
322 369 /// Discard the trained head; auto-tagging reverts to k-NN.
@@ -407,18 +454,13 @@ impl BrowserState {
407 454
408 455 // ── Clustering bootstrap ──
409 456
410 - /// Cluster the library; `cluster_k < 2` means use a default of 8.
457 + /// Cluster the library on a worker thread; `cluster_k < 2` means use a default of 8.
411 458 pub fn run_clustering(&mut self) {
412 459 let k = if self.classifier.cluster_k < 2 { 8 } else { self.classifier.cluster_k as usize };
413 - match self.backend.cluster_library(k) {
414 - Ok(clusters) => {
415 - self.classifier.cluster_names = vec![String::new(); clusters.len()];
416 - self.classifier.clusters = clusters;
417 - self.status = format!("Found {} cluster{}", self.classifier.clusters.len(),
418 - if self.classifier.clusters.len() == 1 { "" } else { "s" });
419 - }
420 - Err(e) => self.status = format!("Clustering failed: {e}"),
421 - }
460 + self.start_classifier_job(
461 + crate::backend::ClassifierJob::Cluster { k },
462 + "Finding clusters\u{2026}",
463 + );
422 464 }
423 465
424 466 /// Apply the named tag to every member of cluster `idx`.
@@ -755,6 +755,19 @@ impl BrowserState {
755 755 self.forge.busy = false;
756 756 self.status = format!("Forge failed: {error}");
757 757 }
758 +
759 + // --- Classifier worker (train / auto-tag / cluster / export) ---
760 + BackendEvent::ClassifierJobDone(result) => {
761 + self.classifier.busy = None;
762 + self.on_classifier_job_done(result);
763 + }
764 + BackendEvent::ClassifierJobFailed(error) => {
765 + let label = self.classifier.busy.take();
766 + let prefix = label.as_deref().unwrap_or("Classifier job").trim_end_matches('\u{2026}');
767 + let msg = format!("{prefix} failed: {error}");
768 + self.classifier.last_error = Some(msg.clone());
769 + self.status = msg;
770 + }
758 771 }
759 772 }
760 773
@@ -1905,6 +1905,19 @@ mod misc {
1905 1905 }
1906 1906 }
1907 1907
1908 + /// Drive a dispatched classifier worker job to completion (the heavy ops now run
1909 + /// off-thread; `poll_workers` applies the result and clears `busy`).
1910 + fn await_classifier_job(state: &mut BrowserState) {
1911 + for _ in 0..4000 {
1912 + if state.classifier.busy.is_none() {
1913 + return;
1914 + }
1915 + state.poll_workers();
1916 + std::thread::sleep(std::time::Duration::from_millis(2));
1917 + }
1918 + panic!("classifier job did not complete");
1919 + }
1920 +
1908 1921 #[test]
1909 1922 fn classifier_train_head_and_route() {
1910 1923 let (mut state, _dir) = make_state();
@@ -1922,14 +1935,16 @@ mod misc {
1922 1935 assert!(state.classifier.head_info.is_none());
1923 1936 assert!(state.classifier.head_readiness.is_some());
1924 1937
1925 - // Train: head appears, covering both tags.
1938 + // Train (off-thread): head appears, covering both tags.
1926 1939 state.classifier_train_head();
1940 + await_classifier_job(&mut state);
1927 1941 let info = state.classifier.head_info.expect("head trained");
1928 1942 assert_eq!(info.class_count, 2);
1929 1943 assert_eq!(info.exemplar_count, 12);
1930 1944
1931 1945 // Library-wide auto-tagging now routes through the head and tags the query.
1932 1946 state.classifier_auto_apply_ml();
1947 + await_classifier_job(&mut state);
1933 1948 let tags = state.backend.get_sample_tags("q1").unwrap();
1934 1949 assert!(tags.contains(&"instrument.drum.kick".to_string()), "got {tags:?}");
1935 1950
@@ -1949,6 +1964,7 @@ mod misc {
1949 1964 src.classifier.export_include_rules = true;
1950 1965 src.classifier.export_include_policy = true;
1951 1966 src.classifier_export_afcl(&path);
1967 + await_classifier_job(&mut src);
1952 1968 assert!(path.exists(), "export wrote the file");
1953 1969
1954 1970 // Destination library: import it as a layer.
@@ -1974,6 +1990,7 @@ mod misc {
1974 1990 insert_featured_sample(&state, "k0", 0.0, Some("x.kick"));
1975 1991 insert_featured_sample(&state, "k1", 0.1, Some("x.kick"));
1976 1992 state.classifier_train_head();
1993 + await_classifier_job(&mut state);
1977 1994 assert!(state.classifier.head_info.is_none());
1978 1995 assert!(state.status.contains("Not enough"));
1979 1996 }
@@ -331,6 +331,19 @@ pub struct ClassifierUiState {
331 331 pub export_include_exemplars: bool,
332 332 pub export_include_rules: bool,
333 333 pub export_include_policy: bool,
334 + /// Inline result line for the last export / import (the footer status is hidden
335 + /// behind the Settings modal, so results are surfaced in-section).
336 + pub last_export_msg: Option<String>,
337 + pub last_import_msg: Option<String>,
338 +
339 + // --- Background-job state (heavy ops run off the GUI thread) ---
340 + /// Label of the in-flight classifier job (train/auto-tag/cluster/export), or `None`.
341 + /// While set, trigger buttons are disabled and a progress line is shown.
342 + pub busy: Option<String>,
343 + /// Last classifier-job error, shown as an in-section banner.
344 + pub last_error: Option<String>,
345 + /// Layer id awaiting a remove confirmation (inline two-step confirm).
346 + pub pending_layer_remove: Option<String>,
334 347 }
335 348
336 349 /// A rule being authored in the builder. Mirrors `audiofiles_core::rules::Rule`
@@ -137,6 +137,20 @@ fn op_needs_value(op: RuleOp) -> bool {
137 137
138 138 /// Draw all tag-classifier sections in Settings.
139 139 pub fn draw_classifier_section(ui: &mut egui::Ui, state: &mut BrowserState) {
140 + // A background job (train/auto-tag/cluster/export) is running: show progress and let
141 + // the disabled buttons below explain why they're inert. Footer status is hidden behind
142 + // the Settings modal, so feedback lives here.
143 + if let Some(label) = state.classifier.busy.clone() {
144 + ui.horizontal(|ui| {
145 + ui.add(egui::Spinner::new().size(14.0));
146 + ui.label(egui::RichText::new(label).small().color(theme::content_secondary()));
147 + });
148 + ui.add_space(theme::space::SM);
149 + } else if let Some(err) = state.classifier.last_error.clone() {
150 + widgets::warning_banner(ui, &err);
151 + ui.add_space(theme::space::SM);
152 + }
153 +
140 154 draw_rules_header(ui, state);
141 155 ui.add_space(theme::space::SM);
142 156 draw_autotag_header(ui, state);
@@ -448,16 +462,17 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) {
448 462 .color(theme::content_muted()),
449 463 );
450 464 ui.add_space(theme::space::SM);
465 + let busy = state.classifier.busy.is_some();
451 466 ui.horizontal(|ui| {
452 467 if ui
453 - .button("Suggest tags across library")
468 + .add_enabled(!busy, egui::Button::new("Suggest tags across library"))
454 469 .on_hover_text("Build the model from your tagged samples and auto-apply confident tags")
455 470 .clicked()
456 471 {
457 472 state.classifier_auto_apply_ml();
458 473 }
459 474 if ui
460 - .button("Undo")
475 + .add_enabled(!busy, egui::Button::new("Undo"))
461 476 .on_hover_text("Remove every tag applied by auto-tagging")
462 477 .clicked()
463 478 {
@@ -570,23 +585,25 @@ fn draw_trained_head(ui: &mut egui::Ui, state: &mut BrowserState) {
570 585 }
571 586
572 587 ui.add_space(theme::space::SM);
588 + let busy = state.classifier.busy.is_some();
589 + let has_head = state.classifier.head_info.is_some();
573 590 ui.horizontal(|ui| {
574 - let train_label =
575 - if state.classifier.head_info.is_some() { "Retrain model" } else { "Train model" };
591 + let train_label = if has_head { "Retrain model" } else { "Train model" };
592 + // Fixed width so the "Train model" ↔ "Retrain model" swap doesn't shift the row.
576 593 if ui
577 - .button(train_label)
594 + .add_enabled(!busy, egui::Button::new(train_label).min_size(egui::vec2(96.0, 0.0)))
578 595 .on_hover_text("Distil your tagged library into a compact per-library classifier")
579 596 .clicked()
580 597 {
581 598 state.classifier_train_head();
582 599 }
583 - if state.classifier.head_info.is_some()
584 - && ui
585 - .button("Clear")
586 - .on_hover_text("Remove the model; auto-tagging reverts to nearest-neighbor matching")
587 - .clicked()
588 - {
589 - state.classifier_clear_head();
600 + if has_head {
601 + let clear = ui
602 + .add_enabled(!busy, egui::Button::new("Clear"))
603 + .on_hover_text("Remove the model; auto-tagging reverts to nearest-neighbor matching");
604 + if clear.clicked() {
605 + state.classifier_clear_head();
606 + }
590 607 }
591 608 });
592 609 }
@@ -605,13 +622,14 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) {
605 622 .color(theme::content_muted()),
606 623 );
607 624 ui.add_space(theme::space::SM);
625 + let busy = state.classifier.busy.is_some();
608 626 ui.horizontal(|ui| {
609 627 let mut k = state.classifier.cluster_k.max(2);
610 628 ui.label("Groups");
611 629 if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() {
612 630 state.classifier.cluster_k = k;
613 631 }
614 - if ui.button("Find clusters").clicked() {
632 + if ui.add_enabled(!busy, egui::Button::new("Find clusters")).clicked() {
615 633 state.classifier.cluster_k = k;
616 634 state.run_clustering();
617 635 }
@@ -748,9 +766,15 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
748 766 .color(theme::content_muted()),
749 767 );
750 768
769 + let busy = state.classifier.busy.is_some();
770 +
751 771 // ── Export ──
752 772 ui.add_space(theme::space::MD);
753 773 widgets::subsection_label(ui, "Export");
774 + // Load what's exportable so the button can disable when there's nothing to share.
775 + state.ensure_head_loaded();
776 + state.ensure_rules_loaded();
777 + state.ensure_policies_loaded();
754 778 ui.horizontal(|ui| {
755 779 ui.label(egui::RichText::new("Name").small().color(theme::content_muted()));
756 780 ui.add(
@@ -764,12 +788,14 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
764 788 ui.checkbox(&mut state.classifier.export_include_rules, "Rules");
765 789 ui.checkbox(&mut state.classifier.export_include_policy, "Thresholds");
766 790 });
767 - let nothing_selected = !state.classifier.export_include_exemplars
768 - && !state.classifier.export_include_rules
769 - && !state.classifier.export_include_policy;
770 - ui.add_enabled_ui(!nothing_selected, |ui| {
771 - if ui
772 - .button("Export to file...")
791 + let exemplars_avail = state.classifier.head_readiness.map(|(n, _)| n > 0).unwrap_or(false);
792 + let rules_avail = !state.classifier.rules.is_empty();
793 + let policy_avail = !state.classifier.policies.is_empty();
794 + let has_content = (state.classifier.export_include_exemplars && exemplars_avail)
795 + || (state.classifier.export_include_rules && rules_avail)
796 + || (state.classifier.export_include_policy && policy_avail);
797 + ui.add_enabled_ui(!busy && has_content, |ui| {
798 + if widgets::primary_button(ui, "Export to file...")
773 799 .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included")
774 800 .clicked()
775 801 && let Some(path) = rfd::FileDialog::new()
@@ -780,19 +806,33 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
780 806 state.classifier_export_afcl(&path);
781 807 }
782 808 });
809 + if !has_content {
810 + ui.label(
811 + egui::RichText::new("Nothing to export yet \u{2014} tag samples or add rules first.")
812 + .small()
813 + .color(theme::content_muted()),
814 + );
815 + }
816 + if let Some(msg) = &state.classifier.last_export_msg {
817 + ui.label(egui::RichText::new(msg).small().color(theme::content_secondary()));
818 + }
783 819
784 820 // ── Import ──
785 821 ui.add_space(theme::space::MD);
786 822 widgets::subsection_label(ui, "Import");
787 - if ui
788 - .button("Import .afcl file...")
789 - .on_hover_text("Add someone's shared classifier as a removable layer")
790 - .clicked()
791 - && let Some(path) = rfd::FileDialog::new()
792 - .add_filter("AF classifier", &["afcl"])
793 - .pick_file()
794 - {
795 - state.classifier_import_afcl(&path);
823 + ui.add_enabled_ui(!busy, |ui| {
824 + if widgets::primary_button(ui, "Import .afcl file...")
825 + .on_hover_text("Add someone's shared classifier as a removable layer")
826 + .clicked()
827 + && let Some(path) = rfd::FileDialog::new()
828 + .add_filter("AF classifier", &["afcl"])
829 + .pick_file()
830 + {
831 + state.classifier_import_afcl(&path);
832 + }
833 + });
834 + if let Some(msg) = &state.classifier.last_import_msg {
835 + ui.label(egui::RichText::new(msg).small().color(theme::content_secondary()));
796 836 }
797 837
798 838 // ── Imported layers ──
@@ -811,40 +851,83 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
811 851 .color(theme::content_muted()),
812 852 );
813 853
854 + let pending = state.classifier.pending_layer_remove.clone();
814 855 let mut toggle: Option<(String, bool)> = None;
815 856 let mut weight_change: Option<(String, f64)> = None;
816 857 let mut remove: Option<String> = None;
858 + let mut set_pending: Option<Option<String>> = None;
817 859 for layer in &layers {
818 860 ui.add_space(theme::space::SM);
861 + let confirming = pending.as_deref() == Some(layer.id.as_str());
862 + // Row A: enable + name (truncated, dimmed when disabled) + counts.
819 863 ui.horizontal(|ui| {
820 864 let mut enabled = layer.enabled;
821 - if ui.checkbox(&mut enabled, "").changed() {
865 + if ui
866 + .checkbox(&mut enabled, "")
867 + .on_hover_text("Use this layer when auto-tagging")
868 + .changed()
869 + {
822 870 toggle = Some((layer.id.clone(), enabled));
823 871 }
824 - ui.label(egui::RichText::new(&layer.name).small().strong());
872 + let name_color =
873 + if layer.enabled { theme::content() } else { theme::content_muted() };
874 + ui.add(
875 + egui::Label::new(egui::RichText::new(&layer.name).small().strong().color(name_color))
876 + .truncate(),
877 + )
878 + .on_hover_text(&layer.name);
825 879 ui.label(
826 880 egui::RichText::new(format!(
827 - "{} exemplar{} \u{00b7} {} rule{}",
881 + "{} ex \u{00b7} {} rule{}",
828 882 layer.exemplar_count,
829 - if layer.exemplar_count == 1 { "" } else { "s" },
830 883 layer.rule_count,
831 884 if layer.rule_count == 1 { "" } else { "s" },
832 885 ))
833 886 .small()
834 887 .color(theme::content_muted()),
835 888 );
836 - if ui.small_button("Remove").clicked() {
837 - remove = Some(layer.id.clone());
838 - }
839 889 });
890 + // Row B: weight + remove (with inline two-step confirm — destructive, no undo).
840 891 ui.horizontal(|ui| {
841 892 ui.label(egui::RichText::new("weight").small().color(theme::content_muted()));
842 893 let mut w = layer.weight as f32;
843 - let r = ui.add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true));
894 + let r = ui
895 + .add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true))
896 + .on_hover_text(
897 + "How much this layer counts when matching. Your own tags are always \
898 + 1.0; lower means weaker.",
899 + );
844 900 if r.drag_stopped() || (r.changed() && !r.dragged()) {
845 901 weight_change = Some((layer.id.clone(), w as f64));
846 902 }
903 + if confirming {
904 + if widgets::danger_small_button(ui, "Remove")
905 + .on_hover_text("Permanently delete this layer and its imported rules")
906 + .clicked()
907 + {
908 + remove = Some(layer.id.clone());
909 + set_pending = Some(None);
910 + }
911 + if ui.small_button("Cancel").clicked() {
912 + set_pending = Some(None);
913 + }
914 + } else if widgets::danger_small_button(ui, "Remove").clicked() {
915 + set_pending = Some(Some(layer.id.clone()));
916 + }
847 917 });
918 + if confirming {
919 + ui.label(
920 + egui::RichText::new(
921 + "Removing deletes this layer's exemplars and imported rules. You'll \
922 + need the .afcl file to add it again.",
923 + )
924 + .small()
925 + .color(theme::danger()),
926 + );
927 + }
928 + }
929 + if let Some(p) = set_pending {
930 + state.classifier.pending_layer_remove = p;
848 931 }
849 932 if let Some((id, enabled)) = toggle {
850 933 state.classifier_set_layer_enabled(&id, enabled);
@@ -16,7 +16,7 @@ use tracing::instrument;
16 16 use super::classify::{FEATURE_VERSION, NUM_FEATURES};
17 17
18 18 /// One cluster: a representative (medoid) sample plus its members.
19 - #[derive(Debug, Clone)]
19 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20 20 pub struct Cluster {
21 21 pub id: usize,
22 22 /// Member nearest the centroid — a playable representative for naming.