Skip to main content

max / audiofiles

Performance: move single-sample suggest to worker, probe bit depth off the DB lock The detail-panel "Suggest tags" built the O(library) exemplar index under the DB lock on the GUI thread. It now runs on the classifier worker via a new ClassifierJob::SuggestSample / ClassifierJobResult::Suggested { hash, outcome }; on_classifier_job_done drops the result if the selection changed while the index built (hash mismatch). The now-redundant Backend::ml_suggest_sample is removed. MlOutcome/MlSuggestion/MlAction/NeighborContribution gain serde derives so they can cross the worker result channel. Device-profile export setup probed each item's file header (bit depth) serially while holding the DB lock on the GUI thread. build_sample_info no longer probes; resolve_device_profile fills bit depth in a parallel rayon pass after releasing the lock (rayon is optional, tied to the device-profiles feature). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:20 UTC
Commit: b0cc2b0f05851510c8df3c06cbf914abd6d88bd5
Parent: 767f573
7 files changed, +91 insertions, -44 deletions
M Cargo.lock +1
@@ -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>,