max / audiofiles
7 files changed,
+91 insertions,
-44 deletions
| @@ -428,6 +428,7 @@ dependencies = [ | |||
| 428 | 428 | "objc2-foundation 0.3.2", | |
| 429 | 429 | "parking_lot", | |
| 430 | 430 | "pollster", | |
| 431 | + | "rayon", | |
| 431 | 432 | "rfd", | |
| 432 | 433 | "rusqlite", | |
| 433 | 434 | "serde", |
| @@ -5,7 +5,7 @@ edition.workspace = true | |||
| 5 | 5 | ||
| 6 | 6 | [features] | |
| 7 | 7 | default = ["device-profiles"] | |
| 8 | - | device-profiles = ["dep:audiofiles-rhai"] | |
| 8 | + | device-profiles = ["dep:audiofiles-rhai", "dep:rayon"] | |
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | 11 | audiofiles-core = { workspace = true } | |
| @@ -26,6 +26,7 @@ serde_json = { workspace = true } | |||
| 26 | 26 | rusqlite = { workspace = true } | |
| 27 | 27 | tracing = { workspace = true } | |
| 28 | 28 | theme-common = { workspace = true } | |
| 29 | + | rayon = { workspace = true, optional = true } | |
| 29 | 30 | ||
| 30 | 31 | [target.'cfg(unix)'.dependencies] | |
| 31 | 32 | libc = { workspace = true } |
| @@ -306,10 +306,27 @@ impl DirectBackend { | |||
| 306 | 306 | if let Some(ref ast) = plugin.hooks.validate_sample { | |
| 307 | 307 | // Collect all sample info with the lock held, then drop it before running scripts | |
| 308 | 308 | // to avoid holding the DB lock during potentially slow Rhai execution. | |
| 309 | - | let infos: Vec<_> = { | |
| 309 | + | let mut infos: Vec<_> = { | |
| 310 | 310 | let db = self.db.lock(); | |
| 311 | 311 | items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect() | |
| 312 | 312 | }; | |
| 313 | + | // Probe bit depth off the DB lock and in parallel: N independent | |
| 314 | + | // file-header reads that previously ran serially *while holding the | |
| 315 | + | // lock* on the GUI thread. rayon bounds the concurrency to the pool. | |
| 316 | + | { | |
| 317 | + | use rayon::prelude::*; | |
| 318 | + | let paths: Vec<Option<std::path::PathBuf>> = items | |
| 319 | + | .iter() | |
| 320 | + | .map(|item| self.store.sample_path(&item.hash, &item.ext).ok()) | |
| 321 | + | .collect(); | |
| 322 | + | let depths: Vec<u16> = paths | |
| 323 | + | .into_par_iter() | |
| 324 | + | .map(|p| p.and_then(|p| probe_bit_depth(&p)).unwrap_or(0)) | |
| 325 | + | .collect(); | |
| 326 | + | for (info, depth) in infos.iter_mut().zip(depths) { | |
| 327 | + | info.bit_depth = depth; | |
| 328 | + | } | |
| 329 | + | } | |
| 313 | 330 | let engine = self.plugin_registry.engine(); | |
| 314 | 331 | let mut keep = vec![true; items.len()]; | |
| 315 | 332 | for (i, info) in infos.into_iter().enumerate() { | |
| @@ -455,19 +472,15 @@ fn build_sample_info( | |||
| 455 | 472 | .unwrap_or(0) | |
| 456 | 473 | }); | |
| 457 | 474 | ||
| 458 | - | // Probe bit depth from the file header (cheap — reads only the header) | |
| 459 | - | let bit_depth = store | |
| 460 | - | .sample_path(&item.hash, &item.ext) | |
| 461 | - | .ok() | |
| 462 | - | .and_then(|p| probe_bit_depth(&p)) | |
| 463 | - | .unwrap_or(0); | |
| 464 | - | ||
| 475 | + | // bit_depth is filled by the caller in a parallel pass off the DB lock — see | |
| 476 | + | // resolve_device_profile. Probing it here would open a file per item while | |
| 477 | + | // holding the DB lock on the GUI thread. | |
| 465 | 478 | audiofiles_rhai::types::RhaiSampleInfo { | |
| 466 | 479 | hash: item.hash.to_string(), | |
| 467 | 480 | name: item.name.clone(), | |
| 468 | 481 | extension: item.ext.clone(), | |
| 469 | 482 | sample_rate, | |
| 470 | - | bit_depth, | |
| 483 | + | bit_depth: 0, | |
| 471 | 484 | channels, | |
| 472 | 485 | duration, | |
| 473 | 486 | file_size, | |
| @@ -807,17 +820,6 @@ impl Backend for DirectBackend { | |||
| 807 | 820 | Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?) | |
| 808 | 821 | } | |
| 809 | 822 | ||
| 810 | - | fn ml_suggest_sample( | |
| 811 | - | &self, | |
| 812 | - | hash: &str, | |
| 813 | - | k: usize, | |
| 814 | - | ) -> BackendResult<audiofiles_core::analysis::exemplar::MlOutcome> { | |
| 815 | - | use audiofiles_core::analysis::exemplar; | |
| 816 | - | let db = self.db.lock(); | |
| 817 | - | let index = exemplar::build_index(&db)?; | |
| 818 | - | Ok(exemplar::preview_sample(&db, hash, &index, k)?) | |
| 819 | - | } | |
| 820 | - | ||
| 821 | 823 | fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> { | |
| 822 | 824 | use audiofiles_core::analysis::{exemplar, trained_head}; | |
| 823 | 825 | let db = self.db.lock(); |
| @@ -250,6 +250,10 @@ pub enum ClassifierJob { | |||
| 250 | 250 | Cluster { k: usize }, | |
| 251 | 251 | /// Export the user's classifier data to a `.afcl` file. | |
| 252 | 252 | Export { path: std::path::PathBuf, opts: audiofiles_core::analysis::afcl::ExportOptions }, | |
| 253 | + | /// k-NN tag suggestions for a single sample. Building the exemplar index is | |
| 254 | + | /// O(library), so the detail-panel "Suggest tags" action runs it here rather | |
| 255 | + | /// than under the DB lock on the GUI thread. | |
| 256 | + | SuggestSample { hash: String, k: usize }, | |
| 253 | 257 | } | |
| 254 | 258 | ||
| 255 | 259 | /// The result of a completed [`ClassifierJob`]. | |
| @@ -259,6 +263,9 @@ pub enum ClassifierJobResult { | |||
| 259 | 263 | AutoApplied(usize), | |
| 260 | 264 | Clustered(Vec<audiofiles_core::analysis::cluster::Cluster>), | |
| 261 | 265 | Exported(std::path::PathBuf), | |
| 266 | + | /// Suggestions for the sample identified by `hash`. The hash lets the GUI | |
| 267 | + | /// drop the result if the selection changed while the job ran. | |
| 268 | + | Suggested { hash: String, outcome: audiofiles_core::analysis::exemplar::MlOutcome }, | |
| 262 | 269 | } | |
| 263 | 270 | ||
| 264 | 271 | /// The core abstraction separating UI from data access. | |
| @@ -473,12 +480,10 @@ pub trait Backend: Send + Sync { | |||
| 473 | 480 | ||
| 474 | 481 | // --- Auto-tagging: k-NN suggestions, thresholds, bootstrap (Layer B) --- | |
| 475 | 482 | ||
| 476 | - | /// Build the exemplar index and score a sample, without writing anything. | |
| 477 | - | fn ml_suggest_sample( | |
| 478 | - | &self, | |
| 479 | - | hash: &str, | |
| 480 | - | k: usize, | |
| 481 | - | ) -> BackendResult<audiofiles_core::analysis::exemplar::MlOutcome>; | |
| 483 | + | // Single-sample k-NN suggestions run on the classifier worker | |
| 484 | + | // (ClassifierJob::SuggestSample), not through a blocking Backend call, because | |
| 485 | + | // building the exemplar index is O(library) and must not hold the DB lock on | |
| 486 | + | // the GUI thread. | |
| 482 | 487 | ||
| 483 | 488 | /// Auto-apply above-threshold tags across the whole library; returns tags applied. | |
| 484 | 489 | fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize>; |
| @@ -69,6 +69,16 @@ fn run_job(db: &Database, job: ClassifierJob) -> ClassifierWorkerEvent { | |||
| 69 | 69 | ClassifierJob::Export { path, opts } => { | |
| 70 | 70 | afcl::export_to_path(db, &path, &opts).map(|()| ClassifierJobResult::Exported(path)) | |
| 71 | 71 | } | |
| 72 | + | ClassifierJob::SuggestSample { hash, k } => { | |
| 73 | + | // Build the O(library) exemplar index and score this one sample off the | |
| 74 | + | // GUI thread (was done under the DB lock on the render thread). | |
| 75 | + | let index = match exemplar::build_index(db) { | |
| 76 | + | Ok(idx) => idx, | |
| 77 | + | Err(e) => return ClassifierWorkerEvent::Failed(e.to_string()), | |
| 78 | + | }; | |
| 79 | + | exemplar::preview_sample(db, &hash, &index, k) | |
| 80 | + | .map(|outcome| ClassifierJobResult::Suggested { hash, outcome }) | |
| 81 | + | } | |
| 72 | 82 | }; | |
| 73 | 83 | match result { | |
| 74 | 84 | Ok(r) => ClassifierWorkerEvent::Done(r), | |
| @@ -158,6 +168,20 @@ mod tests { | |||
| 158 | 168 | } | |
| 159 | 169 | ||
| 160 | 170 | #[test] | |
| 171 | + | fn suggest_sample_job_completes_on_worker() { | |
| 172 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 173 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 174 | + | seed(&db_path); | |
| 175 | + | let job = ClassifierJob::SuggestSample { hash: "k0".to_string(), k: 5 }; | |
| 176 | + | match run_to_completion(db_path, job) { | |
| 177 | + | ClassifierWorkerEvent::Done(ClassifierJobResult::Suggested { hash, .. }) => { | |
| 178 | + | assert_eq!(hash, "k0"); | |
| 179 | + | } | |
| 180 | + | _ => panic!("expected suggestions"), | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[test] | |
| 161 | 185 | fn export_job_writes_file_on_worker() { | |
| 162 | 186 | let dir = tempfile::TempDir::new().unwrap(); | |
| 163 | 187 | let db_path = dir.path().join("audiofiles.db"); |
| @@ -228,6 +228,25 @@ impl BrowserState { | |||
| 228 | 228 | self.classifier.last_export_msg = Some(msg.clone()); | |
| 229 | 229 | self.status = msg; | |
| 230 | 230 | } | |
| 231 | + | R::Suggested { hash, outcome } => { | |
| 232 | + | // Drop the result if the selection moved on while the O(library) | |
| 233 | + | // index built on the worker (the user clicked another sample). | |
| 234 | + | let current = self.selected_node().and_then(|n| n.node.sample_hash.clone()); | |
| 235 | + | if current.as_deref() != Some(hash.as_str()) { | |
| 236 | + | return; | |
| 237 | + | } | |
| 238 | + | let mut all = outcome.auto_applied; | |
| 239 | + | all.extend(outcome.pending_review); | |
| 240 | + | let existing: std::collections::HashSet<&String> = | |
| 241 | + | self.selected_tags.iter().collect(); | |
| 242 | + | all.retain(|s| !existing.contains(&s.tag)); | |
| 243 | + | self.status = if all.is_empty() { | |
| 244 | + | "No tag suggestions from similar samples.".to_string() | |
| 245 | + | } else { | |
| 246 | + | format!("{} tag suggestion{}.", all.len(), if all.len() == 1 { "" } else { "s" }) | |
| 247 | + | }; | |
| 248 | + | self.selected_ml_suggestions = all; | |
| 249 | + | } | |
| 231 | 250 | } | |
| 232 | 251 | } | |
| 233 | 252 | ||
| @@ -419,19 +438,14 @@ impl BrowserState { | |||
| 419 | 438 | let Some(hash) = self.selected_node().and_then(|n| n.node.sample_hash.clone()) else { | |
| 420 | 439 | return; | |
| 421 | 440 | }; | |
| 422 | - | match self.backend.ml_suggest_sample(&hash, DEFAULT_K) { | |
| 423 | - | Ok(outcome) => { | |
| 424 | - | let mut all = outcome.auto_applied; | |
| 425 | - | all.extend(outcome.pending_review); | |
| 426 | - | let existing: std::collections::HashSet<&String> = self.selected_tags.iter().collect(); | |
| 427 | - | all.retain(|s| !existing.contains(&s.tag)); | |
| 428 | - | if all.is_empty() { | |
| 429 | - | self.status = "No tag suggestions from similar samples.".to_string(); | |
| 430 | - | } | |
| 431 | - | self.selected_ml_suggestions = all; | |
| 432 | - | } | |
| 433 | - | Err(e) => self.status = format!("Suggestion failed: {e}"), | |
| 434 | - | } | |
| 441 | + | // Dispatch to the classifier worker: building the exemplar index is | |
| 442 | + | // O(library) and used to run under the DB lock on the GUI thread. The | |
| 443 | + | // result lands in `on_classifier_job_done`, which drops it if the | |
| 444 | + | // selection changed meanwhile (hash mismatch). | |
| 445 | + | self.start_classifier_job( | |
| 446 | + | crate::backend::ClassifierJob::SuggestSample { hash: hash.to_string(), k: DEFAULT_K }, | |
| 447 | + | "Finding similar tags\u{2026}", | |
| 448 | + | ); | |
| 435 | 449 | } | |
| 436 | 450 | ||
| 437 | 451 | /// Accept one k-NN suggestion: apply the tag (`source = 'ml'`) and drop it from |
| @@ -108,7 +108,7 @@ pub struct ExemplarIndex { | |||
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | /// A neighbor that contributed to a tag's score. | |
| 111 | - | #[derive(Debug, Clone)] | |
| 111 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 112 | 112 | pub struct NeighborContribution { | |
| 113 | 113 | pub hash: String, | |
| 114 | 114 | pub weight: f64, | |
| @@ -123,14 +123,14 @@ pub struct TagScore { | |||
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | 125 | /// Whether a scored tag should be auto-applied or queued for review. | |
| 126 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 126 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 127 | 127 | pub enum MlAction { | |
| 128 | 128 | AutoApply, | |
| 129 | 129 | Review, | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | 132 | /// A suggestion with its disposition under the per-tag policy. | |
| 133 | - | #[derive(Debug, Clone)] | |
| 133 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 134 | 134 | pub struct MlSuggestion { | |
| 135 | 135 | pub tag: String, | |
| 136 | 136 | pub score: f64, | |
| @@ -139,7 +139,7 @@ pub struct MlSuggestion { | |||
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 141 | /// Auto-applied vs review-queued suggestions for a sample. | |
| 142 | - | #[derive(Debug, Clone, Default)] | |
| 142 | + | #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] | |
| 143 | 143 | pub struct MlOutcome { | |
| 144 | 144 | pub auto_applied: Vec<MlSuggestion>, | |
| 145 | 145 | pub pending_review: Vec<MlSuggestion>, |