max / audiofiles
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)); |