//! Background classifier worker: runs heavy library-wide classifier operations off the //! GUI thread so the window stays responsive (training, library auto-tagging, clustering, //! and `.afcl` export can each take seconds-to-minutes on a large library). //! //! Mirrors `cleanup.rs`: a one-shot thread with its own `Database` connection (WAL + //! `busy_timeout`, so it runs concurrently with the GUI connection), reporting back over a //! channel the GUI polls each frame. One job runs at a time, the UI disables the trigger //! buttons while a job is in flight, and dispatching a new job drops (joins) the prior one. use std::path::PathBuf; use tracing::{error, instrument}; use audiofiles_core::analysis::{afcl, cluster, exemplar, trained_head}; use audiofiles_core::db::Database; use audiofiles_core::worker_runtime::{JobHandle, spawn_job}; use crate::backend::{ClassifierJob, ClassifierJobResult, TrainedHeadInfo}; /// Event sent from the classifier worker back to the GUI thread. pub enum ClassifierWorkerEvent { Done(ClassifierJobResult), Failed(String), } /// Handle for a single in-flight classifier job. Thin wrapper over the shared /// one-shot [`JobHandle`], which owns the result channel, the `catch_unwind` /// panic-isolation, and the join-on-drop. pub struct ClassifierHandle(JobHandle); impl ClassifierHandle { /// Poll for the job's result without blocking. pub fn try_recv(&self) -> Option { self.0.try_recv() } } // Join-on-drop is owned by the inner JobHandle (so a replaced job finishes cleanly). /// Spawn a one-shot worker thread to run `job` against the database at `db_path`. /// /// The shared [`spawn_job`] runtime owns the panic isolation: a panic in the job /// (or `Database::open`) becomes a `Failed` event rather than a silently-dead /// thread, so the GUI's `try_recv` always resolves and the trigger buttons /// re-enable. #[instrument(skip_all)] pub fn spawn_classifier_job( db_path: PathBuf, job: ClassifierJob, ) -> std::io::Result { let handle = spawn_job( "classifier-job", || ClassifierWorkerEvent::Failed("classifier job panicked (internal error)".to_string()), move || match Database::open(&db_path) { Ok(db) => run_job(&db, job), Err(e) => { error!("Classifier worker failed to open database: {e}"); ClassifierWorkerEvent::Failed(format!("could not open database: {e}")) } }, )?; Ok(ClassifierHandle(handle)) } fn run_job(db: &Database, job: ClassifierJob) -> ClassifierWorkerEvent { let result = match job { ClassifierJob::TrainHead => trained_head::train_and_save(db) .map(|h| ClassifierJobResult::Trained(h.map(|head| head_info(&head)))), ClassifierJob::AutoApply { k } => auto_apply(db, k).map(ClassifierJobResult::AutoApplied), ClassifierJob::Cluster { k } => { cluster::cluster_library(db, k, 50).map(|r| ClassifierJobResult::Clustered(r.clusters)) } ClassifierJob::Export { path, opts } => { afcl::export_to_path(db, &path, &opts).map(|()| ClassifierJobResult::Exported(path)) } ClassifierJob::SuggestSample { hash, k } => { // Build the O(library) exemplar index and score this one sample off the // GUI thread (was done under the DB lock on the render thread). let index = match exemplar::build_index(db) { Ok(idx) => idx, Err(e) => return ClassifierWorkerEvent::Failed(e.to_string()), }; exemplar::preview_sample(db, &hash, &index, k) .map(|outcome| ClassifierJobResult::Suggested { hash, outcome }) } }; match result { Ok(r) => ClassifierWorkerEvent::Done(r), Err(e) => ClassifierWorkerEvent::Failed(e.to_string()), } } /// Library auto-tagging: route through the trained head when one is current (O(tags) per /// sample), else brute-force k-NN. Mirrors `DirectBackend::ml_auto_apply_all`. fn auto_apply(db: &Database, k: usize) -> audiofiles_core::error::Result { if let Some(head) = trained_head::load(db)? { trained_head::auto_apply_library(db, &head) } else { exemplar::auto_apply_library(db, k) } } fn head_info(h: &trained_head::TrainedHead) -> TrainedHeadInfo { TrainedHeadInfo { class_count: h.classes.len(), exemplar_count: h.exemplar_count, trained_at: h.trained_at, } } #[cfg(test)] mod tests { use super::*; use audiofiles_core::analysis::classify::{FEATURE_VERSION, NUM_FEATURES}; fn seed(db_path: &std::path::Path) { let db = Database::open(db_path).unwrap(); for i in 0..6 { let hash = format!("k{i}"); db.conn() .execute( "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \ VALUES (?1, ?1, 'wav', 1, 0, 0)", [&hash], ) .unwrap(); let v = serde_json::to_string(&vec![0.01 * i as f64; NUM_FEATURES]).unwrap(); db.conn() .execute( "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)", rusqlite::params![hash, FEATURE_VERSION, v], ) .unwrap(); audiofiles_core::tags::add_tag(&db, &hash, "instrument.drum.kick").unwrap(); } } fn run_to_completion(db_path: PathBuf, job: ClassifierJob) -> ClassifierWorkerEvent { let handle = spawn_classifier_job(db_path, job).unwrap(); loop { if let Some(ev) = handle.try_recv() { return ev; } std::thread::sleep(std::time::Duration::from_millis(5)); } } #[test] fn train_job_completes_on_worker() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); seed(&db_path); match run_to_completion(db_path, ClassifierJob::TrainHead) { ClassifierWorkerEvent::Done(ClassifierJobResult::Trained(Some(info))) => { assert_eq!(info.class_count, 1); } _ => panic!("expected a trained head"), } } #[test] fn cluster_job_completes_on_worker() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); seed(&db_path); match run_to_completion(db_path, ClassifierJob::Cluster { k: 2 }) { ClassifierWorkerEvent::Done(ClassifierJobResult::Clustered(clusters)) => { assert!(!clusters.is_empty()); } _ => panic!("expected clusters"), } } #[test] fn suggest_sample_job_completes_on_worker() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); seed(&db_path); let job = ClassifierJob::SuggestSample { hash: "k0".to_string(), k: 5, }; match run_to_completion(db_path, job) { ClassifierWorkerEvent::Done(ClassifierJobResult::Suggested { hash, .. }) => { assert_eq!(hash, "k0"); } _ => panic!("expected suggestions"), } } #[test] fn export_job_writes_file_on_worker() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); seed(&db_path); let out = dir.path().join("out.afcl"); let job = ClassifierJob::Export { path: out.clone(), opts: afcl::ExportOptions::default(), }; match run_to_completion(db_path, job) { ClassifierWorkerEvent::Done(ClassifierJobResult::Exported(p)) => { assert_eq!(p, out); assert!(out.exists()); } _ => panic!("expected export"), } } }