Skip to main content

max / audiofiles

Wire rules into analysis pipeline + feature backfill Apply deterministic tag rules to every freshly analyzed sample, and backfill feature vectors for libraries imported before the sample_features store existed. - analysis::samples_needing_features lists non-deleted samples lacking a current-FEATURE_VERSION vector; exposed via Backend::samples_needing_features. - BrowserState::start_backfill reuses the analysis worker but suppresses the review/progress screens (silent, status line only), yields to user-initiated analysis, and is resumable. Triggered once at launch in init_browser; no-op for new/covered libraries. - Backend::apply_rules_to_sample called right after save_analysis in the AnalysisSampleDone handler, so import / re-analysis / backfill all apply rules. No-op while the rule set is empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-14 23:33 UTC
Commit: 3ce0f4d9b074b2704c4ea154061d8e1e9a16b731
Parent: 4d2ed15
7 files changed, +164 insertions, -22 deletions
@@ -421,6 +421,10 @@ fn init_browser(data_dir: &Path, shared: Arc<SharedState>, vault_name: &str) ->
421 421 browser.import_path(&path);
422 422 }
423 423 }
424 + // Backfill feature vectors for samples imported before the feature store
425 + // existed. No-op for new/covered libraries; skips if a CLI import is already
426 + // running (it'll resume on next launch). Runs silently in the background.
427 + browser.start_backfill();
424 428 (Some(browser), None)
425 429 }
426 430 Err(e) => {
@@ -571,6 +571,16 @@ impl Backend for DirectBackend {
571 571 Ok(())
572 572 }
573 573
574 + fn apply_rules_to_sample(&self, hash: &str) -> BackendResult<bool> {
575 + let db = self.db.lock();
576 + Ok(audiofiles_core::rules::apply_rules_to_sample(&db, hash)?)
577 + }
578 +
579 + fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>> {
580 + let db = self.db.lock();
581 + Ok(audiofiles_core::analysis::samples_needing_features(&db)?)
582 + }
583 +
574 584 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
575 585 let db = self.db.lock();
576 586 Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash))
@@ -346,6 +346,14 @@ pub trait Backend: Send + Sync {
346 346 /// Save an analysis result to the database.
347 347 fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()>;
348 348
349 + /// Apply the deterministic tag rules to a sample, reconciling rule-sourced tags.
350 + /// Returns whether any tag changed. No-op while the rule set is empty.
351 + fn apply_rules_to_sample(&self, hash: &str) -> BackendResult<bool>;
352 +
353 + /// Hashes (+ extension) of samples lacking a current-version feature vector
354 + /// (the feature-backfill work-list).
355 + fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>>;
356 +
349 357 /// Get waveform display data for a sample.
350 358 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>;
351 359
@@ -198,6 +198,32 @@ impl BrowserState {
198 198 }
199 199 }
200 200
201 + /// Silently recompute feature vectors for samples that lack a current-version one
202 + /// (libraries imported before the `sample_features` store existed, or after a
203 + /// feature-extractor version bump). Reuses the analysis worker but suppresses the
204 + /// review/progress screens, and yields to user-initiated analysis (which cancels and
205 + /// replaces the worker). Resumable: leftover samples are picked up on the next call.
206 + pub fn start_backfill(&mut self) {
207 + if self.backfill_in_progress || !matches!(self.import_mode, ImportMode::None) {
208 + return;
209 + }
210 + let needed = self.backend.samples_needing_features().unwrap_or_default();
211 + if needed.is_empty() {
212 + return;
213 + }
214 + let count = needed.len();
215 + self.backfill_in_progress = true;
216 + self.status = format!("Backfilling features for {count} samples…");
217 + if self
218 + .backend
219 + .start_analysis(needed, AnalysisConfig::default())
220 + .is_err()
221 + {
222 + self.backfill_in_progress = false;
223 + self.status = "Feature backfill could not start".to_string();
224 + }
225 + }
226 +
201 227 /// Begin the analysis workflow by showing the configuration screen.
202 228 pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) {
203 229 if sample_hashes.is_empty() {
@@ -516,11 +542,16 @@ impl BrowserState {
516 542 total,
517 543 current_name,
518 544 } => {
519 - self.import_mode = ImportMode::Analyzing {
520 - completed,
521 - total,
522 - current_name,
523 - };
545 + if self.backfill_in_progress {
546 + // Silent: keep the normal browser view, just a status line.
547 + self.status = format!("Backfilling features… {completed}/{total}");
548 + } else {
549 + self.import_mode = ImportMode::Analyzing {
550 + completed,
551 + total,
552 + current_name,
553 + };
554 + }
524 555 }
525 556 BackendEvent::AnalysisSampleDone {
526 557 result,
@@ -528,23 +559,29 @@ impl BrowserState {
528 559 } => {
529 560 let result = *result;
530 561 let _ = self.backend.save_analysis(&result);
531 -
532 - let hash = audiofiles_core::SampleHash::new(result.hash.clone());
533 - let name = self.backend.sample_original_name(&hash)
534 - .unwrap_or_else(|_| hash.to_string());
535 -
536 - self.pending_review_items.push(ReviewItem {
537 - hash,
538 - name,
539 - suggestions: suggestions
540 - .into_iter()
541 - .map(|s| SuggestionState {
542 - accepted: true,
543 - suggestion: s,
544 - })
545 - .collect(),
546 - result,
547 - });
562 + // Pipeline wiring: apply deterministic tag rules to the freshly
563 + // analyzed sample (no-op while the rule set is empty).
564 + let _ = self.backend.apply_rules_to_sample(&result.hash);
565 +
566 + // Backfill: persist vectors + apply rules only — no review queue.
567 + if !self.backfill_in_progress {
568 + let hash = audiofiles_core::SampleHash::new(result.hash.clone());
569 + let name = self.backend.sample_original_name(&hash)
570 + .unwrap_or_else(|_| hash.to_string());
571 +
572 + self.pending_review_items.push(ReviewItem {
573 + hash,
574 + name,
575 + suggestions: suggestions
576 + .into_iter()
577 + .map(|s| SuggestionState {
578 + accepted: true,
579 + suggestion: s,
580 + })
581 + .collect(),
582 + result,
583 + });
584 + }
548 585 }
549 586 BackendEvent::AnalysisSampleError { hash, error } => {
550 587 let name = self.backend.sample_original_name(&hash)
@@ -552,6 +589,19 @@ impl BrowserState {
552 589 self.analysis_errors.push(super::AnalysisFileError { hash, name, error });
553 590 }
554 591 BackendEvent::AnalysisBatchComplete => {
592 + if self.backfill_in_progress {
593 + self.backfill_in_progress = false;
594 + let errors = self.analysis_errors.len();
595 + self.analysis_errors.clear();
596 + self.pending_review_items.clear();
597 + self.refresh_contents();
598 + self.status = if errors == 0 {
599 + "Feature backfill complete".to_string()
600 + } else {
601 + format!("Feature backfill complete ({errors} skipped)")
602 + };
603 + return true;
604 + }
555 605 let items = std::mem::take(&mut self.pending_review_items);
556 606 let error_count = self.analysis_errors.len();
557 607 if self.quick_import {
@@ -210,6 +210,10 @@ pub struct BrowserState {
210 210 pub bulk_move_filter: String,
211 211 pub pending_review_items: Vec<ReviewItem>,
212 212
213 + /// True while a silent feature-backfill batch is running (reuses the analysis
214 + /// worker but suppresses the review/progress screens). See `start_backfill`.
215 + pub backfill_in_progress: bool,
216 +
213 217 // Error accumulation for import/analysis workflows
214 218 pub import_file_errors: Vec<ImportFileError>,
215 219 pub analysis_errors: Vec<AnalysisFileError>,
@@ -505,6 +509,7 @@ impl BrowserState {
505 509 // M-6.
506 510 bulk_move_filter: String::new(),
507 511 pending_review_items: Vec::new(),
512 + backfill_in_progress: false,
508 513 import_file_errors: Vec::new(),
509 514 analysis_errors: Vec::new(),
510 515 import_errors_expanded: false,
@@ -1815,4 +1815,28 @@ mod misc {
1815 1815 assert!(state.edit.last_undo.is_none());
1816 1816 assert_eq!(state.status, prior_status);
1817 1817 }
1818 +
1819 + #[test]
1820 + fn backfill_noop_when_nothing_needed() {
1821 + let (mut state, _dir) = make_state();
1822 + // Empty library: nothing to backfill, no worker started.
1823 + state.start_backfill();
1824 + assert!(!state.backfill_in_progress);
1825 + }
1826 +
1827 + #[test]
1828 + fn samples_needing_features_detects_unanalyzed() {
1829 + let (state, _dir) = make_state();
1830 + insert_fake_sample(&state, "aaa111");
1831 + let needed = state.backend.samples_needing_features().unwrap();
1832 + assert!(needed.iter().any(|(h, _)| h == "aaa111"));
1833 + }
1834 +
1835 + #[test]
1836 + fn apply_rules_to_sample_noop_without_rules() {
1837 + let (state, _dir) = make_state();
1838 + insert_fake_sample(&state, "aaa111");
1839 + // No rules configured -> no change.
1840 + assert!(!state.backend.apply_rules_to_sample("aaa111").unwrap());
1841 + }
1818 1842 }
@@ -398,6 +398,24 @@ pub fn load_analysis(db: &Database, hash: &str) -> Option<AnalysisResult> {
398 398 .ok()
399 399 }
400 400
401 + /// Hashes (+ file extension) of non-deleted samples lacking a current-`FEATURE_VERSION`
402 + /// feature vector — the backfill work-list for libraries imported before the
403 + /// `sample_features` store existed (or after a feature-extractor version bump).
404 + #[instrument(skip_all)]
405 + pub fn samples_needing_features(db: &Database) -> Result<Vec<(String, String)>, CoreError> {
406 + let mut stmt = db.conn().prepare(
407 + "SELECT s.hash, s.file_extension FROM samples s
408 + WHERE s.deleted_at IS NULL
409 + AND NOT EXISTS (
410 + SELECT 1 FROM sample_features f
411 + WHERE f.hash = s.hash AND f.feat_version = ?1)",
412 + )?;
413 + let rows = stmt.query_map([classify::FEATURE_VERSION], |row| {
414 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
415 + })?;
416 + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
417 + }
418 +
401 419 #[cfg(test)]
402 420 mod tests {
403 421 use super::*;
@@ -566,6 +584,29 @@ mod tests {
566 584 }
567 585
568 586 #[test]
587 + fn samples_needing_features_lists_uncovered() {
588 + let db = Database::open_in_memory().unwrap();
589 + for (h, ext) in [("a", "wav"), ("b", "aiff")] {
590 + db.conn()
591 + .execute(
592 + "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
593 + VALUES (?1, ?1, ?2, 1, 0, 0)",
594 + rusqlite::params![h, ext],
595 + )
596 + .unwrap();
597 + }
598 + // 'a' already has a current-version vector; 'b' does not.
599 + db.conn()
600 + .execute(
601 + "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES ('a', ?1, '[]', 0)",
602 + [classify::FEATURE_VERSION],
603 + )
604 + .unwrap();
605 + let need = samples_needing_features(&db).unwrap();
606 + assert_eq!(need, vec![("b".to_string(), "aiff".to_string())]);
607 + }
608 +
609 + #[test]
569 610 fn smart_skip_gates_on_cheap_features() {
570 611 // BPM/loop: short one-shots skip, longer clips run.
571 612 assert!(!should_detect_bpm(0.3));