max / audiofiles
4 files changed,
+328 insertions,
-200 deletions
| @@ -27,10 +27,13 @@ use super::search_worker::{self, SearchCommand, SearchEvent}; | |||
| 27 | 27 | use parking_lot::Mutex; | |
| 28 | 28 | ||
| 29 | 29 | use super::{ | |
| 30 | - | Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, | |
| 30 | + | Backend, VfsBackend, TagBackend, SearchBackend, CollectionBackend, AnalysisBackend, RulesBackend, ClassifierBackend, SimilarityBackend, StoreBackend, ExportBackend, ForgeBackend, ConfigBackend, OperationsBackend, | |
| 31 | + | BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, | |
| 31 | 32 | ImportStrategyDesc, ImportedFolderDesc, TrainedHeadInfo, | |
| 32 | 33 | }; | |
| 33 | 34 | ||
| 35 | + | use super::forge::{ForgePending, ForgeImportOutcome, forge_import_chop, forge_import_conform}; | |
| 36 | + | ||
| 34 | 37 | use crate::cleanup::{CleanupCommand, CleanupHandle}; | |
| 35 | 38 | use crate::export::{ExportCommand, ExportHandle}; | |
| 36 | 39 | use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; | |
| @@ -38,60 +41,6 @@ use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; | |||
| 38 | 41 | use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; | |
| 39 | 42 | use audiofiles_core::forge::{ForgeCommand, ForgeEvent}; | |
| 40 | 43 | ||
| 41 | - | /// Import context for the in-flight forge operation. The worker does the CPU | |
| 42 | - | /// work (decode/slice/conform/encode → temp files); this carries the VFS | |
| 43 | - | /// destination so the GUI thread can import the staged files when the worker | |
| 44 | - | /// signals completion. | |
| 45 | - | enum ForgePending { | |
| 46 | - | Chop { | |
| 47 | - | vfs_id: VfsId, | |
| 48 | - | parent_id: Option<NodeId>, | |
| 49 | - | }, | |
| 50 | - | Conform { | |
| 51 | - | vfs_id: VfsId, | |
| 52 | - | parent_id: Option<NodeId>, | |
| 53 | - | sample_rate: u32, | |
| 54 | - | bit_depth: u16, | |
| 55 | - | }, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /// GUI-ready result of an off-thread forge import job. The import (blob copy + DB | |
| 59 | - | /// writes) runs on its own WAL connection so it never holds the GUI's `db.lock()`; | |
| 60 | - | /// `poll_workers` drains this and emits the matching `BackendEvent` on a later frame. | |
| 61 | - | enum ForgeImportOutcome { | |
| 62 | - | Chop { slice_count: usize }, | |
| 63 | - | Conform { | |
| 64 | - | sample_rate: u32, | |
| 65 | - | bit_depth: u16, | |
| 66 | - | overshoot: Option<super::ForgeOvershoot>, | |
| 67 | - | }, | |
| 68 | - | Error { error: String }, | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | /// Open a fresh WAL connection + store and import chop slices. Runs on a job | |
| 72 | - | /// thread (the established per-worker connection pattern) so the GUI's db.lock() | |
| 73 | - | /// is never held during the blob copies + row writes. | |
| 74 | - | fn forge_import_chop( | |
| 75 | - | data_dir: &Path, | |
| 76 | - | root: &Path, | |
| 77 | - | vfs_id: VfsId, | |
| 78 | - | parent_id: Option<NodeId>, | |
| 79 | - | staging: audiofiles_core::forge::ChopStaging, | |
| 80 | - | ) -> ForgeImportOutcome { | |
| 81 | - | let db = match Database::open(data_dir.join("audiofiles.db")) { | |
| 82 | - | Ok(d) => d, | |
| 83 | - | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 84 | - | }; | |
| 85 | - | let store = match SampleStore::new(root) { | |
| 86 | - | Ok(s) => s, | |
| 87 | - | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 88 | - | }; | |
| 89 | - | match audiofiles_core::forge::import_chop(&store, &db, vfs_id, parent_id, staging) { | |
| 90 | - | Ok(r) => ForgeImportOutcome::Chop { slice_count: r.slice_count }, | |
| 91 | - | Err(e) => ForgeImportOutcome::Error { error: e.to_string() }, | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | ||
| 95 | 44 | /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`]. | |
| 96 | 45 | /// Shared by the directory and explicit-file import entry points. | |
| 97 | 46 | fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { | |
| @@ -106,43 +55,6 @@ fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { | |||
| 106 | 55 | } | |
| 107 | 56 | } | |
| 108 | 57 | ||
| 109 | - | /// Off-thread counterpart for conform import (see [`forge_import_chop`]). Maps the | |
| 110 | - | /// core overshoot result into the GUI-facing `ForgeOvershoot` here so the outcome | |
| 111 | - | /// is ready to emit. | |
| 112 | - | #[allow(clippy::too_many_arguments)] | |
| 113 | - | fn forge_import_conform( | |
| 114 | - | data_dir: &Path, | |
| 115 | - | root: &Path, | |
| 116 | - | vfs_id: VfsId, | |
| 117 | - | parent_id: Option<NodeId>, | |
| 118 | - | sample_rate: u32, | |
| 119 | - | bit_depth: u16, | |
| 120 | - | staging: audiofiles_core::forge::ConformStaging, | |
| 121 | - | ) -> ForgeImportOutcome { | |
| 122 | - | let db = match Database::open(data_dir.join("audiofiles.db")) { | |
| 123 | - | Ok(d) => d, | |
| 124 | - | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 125 | - | }; | |
| 126 | - | let store = match SampleStore::new(root) { | |
| 127 | - | Ok(s) => s, | |
| 128 | - | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 129 | - | }; | |
| 130 | - | match audiofiles_core::forge::import_conform(&store, &db, vfs_id, parent_id, staging) { | |
| 131 | - | Ok(r) => { | |
| 132 | - | let overshoot = r.overshoot.map(|o| match o.action { | |
| 133 | - | audiofiles_core::forge::OvershootAction::Trimmed { gain_db } => { | |
| 134 | - | super::ForgeOvershoot::Trimmed { peak_dbfs: o.peak_dbfs, gain_db } | |
| 135 | - | } | |
| 136 | - | audiofiles_core::forge::OvershootAction::Flagged => { | |
| 137 | - | super::ForgeOvershoot::Flagged { peak_dbfs: o.peak_dbfs } | |
| 138 | - | } | |
| 139 | - | }); | |
| 140 | - | ForgeImportOutcome::Conform { sample_rate, bit_depth, overshoot } | |
| 141 | - | } | |
| 142 | - | Err(e) => ForgeImportOutcome::Error { error: e.to_string() }, | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | 58 | /// Direct backend: talks to SQLite and the sample store in-process. | |
| 147 | 59 | pub struct DirectBackend { | |
| 148 | 60 | db: Mutex<Database>, | |
| @@ -340,7 +252,7 @@ impl DirectBackend { | |||
| 340 | 252 | // to avoid holding the DB lock during potentially slow Rhai execution. | |
| 341 | 253 | let mut infos: Vec<_> = { | |
| 342 | 254 | let db = self.db.lock(); | |
| 343 | - | items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect() | |
| 255 | + | items.iter().map(|item| super::sample_info::build_sample_info(&db, &self.store, item)).collect() | |
| 344 | 256 | }; | |
| 345 | 257 | // Probe bit depth off the DB lock and in parallel: N independent | |
| 346 | 258 | // file-header reads that previously ran serially *while holding the | |
| @@ -353,7 +265,7 @@ impl DirectBackend { | |||
| 353 | 265 | .collect(); | |
| 354 | 266 | let depths: Vec<u16> = paths | |
| 355 | 267 | .into_par_iter() | |
| 356 | - | .map(|p| p.and_then(|p| probe_bit_depth(&p)).unwrap_or(0)) | |
| 268 | + | .map(|p| p.and_then(|p| super::sample_info::probe_bit_depth(&p)).unwrap_or(0)) | |
| 357 | 269 | .collect(); | |
| 358 | 270 | for (info, depth) in infos.iter_mut().zip(depths) { | |
| 359 | 271 | info.bit_depth = depth; | |
| @@ -441,111 +353,7 @@ impl DirectBackend { | |||
| 441 | 353 | #[cfg(feature = "device-profiles")] | |
| 442 | 354 | use audiofiles_core::util::split_name_ext; | |
| 443 | 355 | ||
| 444 | - | /// Build a RhaiSampleInfo from an ExportItem and database lookups. | |
| 445 | - | #[cfg(feature = "device-profiles")] | |
| 446 | - | fn build_sample_info( | |
| 447 | - | db: &audiofiles_core::db::Database, | |
| 448 | - | store: &audiofiles_core::store::SampleStore, | |
| 449 | - | item: &ExportItem, | |
| 450 | - | ) -> audiofiles_rhai::types::RhaiSampleInfo { | |
| 451 | - | // Query audio_analysis for sample_rate and channels. Read the integers as | |
| 452 | - | // i64 so a valid-but-out-of-range stored value isn't reported as a query | |
| 453 | - | // error and lumped in with the legitimate "no analysis row yet" case; range | |
| 454 | - | // problems are surfaced explicitly below instead. Only QueryReturnedNoRows | |
| 455 | - | // is a silent (0,0) fallback — a genuine read error or a corrupt value | |
| 456 | - | // (negative / oversized rate or channels) is logged so a device profile | |
| 457 | - | // can't silently drop the sample on mystery zeros. | |
| 458 | - | let (sample_rate, channels, duration) = db | |
| 459 | - | .conn() | |
| 460 | - | .query_row( | |
| 461 | - | "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1", | |
| 462 | - | [&item.hash], | |
| 463 | - | |row| { | |
| 464 | - | Ok(( | |
| 465 | - | row.get::<_, i64>(0)?, | |
| 466 | - | row.get::<_, i64>(1)?, | |
| 467 | - | row.get::<_, f64>(2)?, | |
| 468 | - | )) | |
| 469 | - | }, | |
| 470 | - | ) | |
| 471 | - | .map(|(sr, ch, dur)| { | |
| 472 | - | let sample_rate = u32::try_from(sr).unwrap_or_else(|_| { | |
| 473 | - | tracing::warn!("Corrupt sample_rate {sr} for {}", &item.hash[..8]); | |
| 474 | - | 0 | |
| 475 | - | }); | |
| 476 | - | let channels = u16::try_from(ch).unwrap_or_else(|_| { | |
| 477 | - | tracing::warn!("Corrupt channels {ch} for {}", &item.hash[..8]); | |
| 478 | - | 0 | |
| 479 | - | }); | |
| 480 | - | (sample_rate, channels, dur) | |
| 481 | - | }) | |
| 482 | - | .unwrap_or_else(|e| { | |
| 483 | - | if !matches!(e, rusqlite::Error::QueryReturnedNoRows) { | |
| 484 | - | tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]); | |
| 485 | - | } | |
| 486 | - | (0, 0, item.duration.unwrap_or(0.0)) | |
| 487 | - | }); | |
| 488 | - | ||
| 489 | - | // Query samples for file_size | |
| 490 | - | let file_size = db | |
| 491 | - | .conn() | |
| 492 | - | .query_row( | |
| 493 | - | "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL", | |
| 494 | - | [&item.hash], | |
| 495 | - | |row| row.get::<_, u64>(0), | |
| 496 | - | ) | |
| 497 | - | .unwrap_or_else(|_| { | |
| 498 | - | // Fallback: read from store | |
| 499 | - | store | |
| 500 | - | .sample_path(&item.hash, &item.ext) | |
| 501 | - | .ok() | |
| 502 | - | .and_then(|p| std::fs::metadata(p).ok()) | |
| 503 | - | .map(|m| m.len()) | |
| 504 | - | .unwrap_or(0) | |
| 505 | - | }); | |
| 506 | - | ||
| 507 | - | // bit_depth is filled by the caller in a parallel pass off the DB lock — see | |
| 508 | - | // resolve_device_profile. Probing it here would open a file per item while | |
| 509 | - | // holding the DB lock on the GUI thread. | |
| 510 | - | audiofiles_rhai::types::RhaiSampleInfo { | |
| 511 | - | hash: item.hash.to_string(), | |
| 512 | - | name: item.name.clone(), | |
| 513 | - | extension: item.ext.clone(), | |
| 514 | - | sample_rate, | |
| 515 | - | bit_depth: 0, | |
| 516 | - | channels, | |
| 517 | - | duration, | |
| 518 | - | file_size, | |
| 519 | - | } | |
| 520 | - | } | |
| 521 | - | ||
| 522 | - | /// Probe bit depth from a WAV/AIFF file header without full decode. | |
| 523 | - | #[cfg(feature = "device-profiles")] | |
| 524 | - | fn probe_bit_depth(path: &std::path::Path) -> Option<u16> { | |
| 525 | - | // Try hound first (WAV) | |
| 526 | - | if let Ok(reader) = hound::WavReader::open(path) { | |
| 527 | - | return Some(reader.spec().bits_per_sample); | |
| 528 | - | } | |
| 529 | - | // Try symphonia for AIFF/other formats | |
| 530 | - | let file = std::fs::File::open(path).ok()?; | |
| 531 | - | let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default()); | |
| 532 | - | let mut hint = symphonia::core::probe::Hint::new(); | |
| 533 | - | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { | |
| 534 | - | hint.with_extension(ext); | |
| 535 | - | } | |
| 536 | - | let probed = symphonia::default::get_probe() | |
| 537 | - | .format( | |
| 538 | - | &hint, | |
| 539 | - | mss, | |
| 540 | - | &symphonia::core::formats::FormatOptions::default(), | |
| 541 | - | &symphonia::core::meta::MetadataOptions::default(), | |
| 542 | - | ) | |
| 543 | - | .ok()?; | |
| 544 | - | let track = probed.format.default_track()?; | |
| 545 | - | track.codec_params.bits_per_sample.map(|b| b as u16) | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | impl Backend for DirectBackend { | |
| 356 | + | impl VfsBackend for DirectBackend { | |
| 549 | 357 | // --- VFS --- | |
| 550 | 358 | ||
| 551 | 359 | fn list_vfs(&self) -> BackendResult<Vec<Vfs>> { | |
| @@ -664,6 +472,9 @@ impl Backend for DirectBackend { | |||
| 664 | 472 | let db = self.db.lock(); | |
| 665 | 473 | Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?) | |
| 666 | 474 | } | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | impl TagBackend for DirectBackend { | |
| 667 | 478 | ||
| 668 | 479 | // --- Tags --- | |
| 669 | 480 | ||
| @@ -715,6 +526,9 @@ impl Backend for DirectBackend { | |||
| 715 | 526 | let db = self.db.lock(); | |
| 716 | 527 | Ok(tags::remove_tag_globally(&db, tag)?) | |
| 717 | 528 | } | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | impl SearchBackend for DirectBackend { | |
| 718 | 532 | ||
| 719 | 533 | // --- Search --- | |
| 720 | 534 | ||
| @@ -732,6 +546,9 @@ impl Backend for DirectBackend { | |||
| 732 | 546 | let db = self.db.lock(); | |
| 733 | 547 | Ok(search::search_global(&db, filter)?) | |
| 734 | 548 | } | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | impl CollectionBackend for DirectBackend { | |
| 735 | 552 | ||
| 736 | 553 | // --- Collections --- | |
| 737 | 554 | ||
| @@ -781,6 +598,9 @@ impl Backend for DirectBackend { | |||
| 781 | 598 | let db = self.db.lock(); | |
| 782 | 599 | Ok(collections::get_sample_collections(&db, sample_hash)?) | |
| 783 | 600 | } | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | impl AnalysisBackend for DirectBackend { | |
| 784 | 604 | ||
| 785 | 605 | // --- Analysis --- | |
| 786 | 606 | ||
| @@ -820,6 +640,9 @@ impl Backend for DirectBackend { | |||
| 820 | 640 | let db = self.db.lock(); | |
| 821 | 641 | Ok(audiofiles_core::analysis::samples_needing_features(&db)?) | |
| 822 | 642 | } | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | impl RulesBackend for DirectBackend { | |
| 823 | 646 | ||
| 824 | 647 | fn list_rules(&self) -> BackendResult<Vec<audiofiles_core::rules::Rule>> { | |
| 825 | 648 | let db = self.db.lock(); | |
| @@ -866,6 +689,9 @@ impl Backend for DirectBackend { | |||
| 866 | 689 | let db = self.db.lock(); | |
| 867 | 690 | Ok(audiofiles_core::rules::sample_tag_provenance(&db, hash)?) | |
| 868 | 691 | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | impl ClassifierBackend for DirectBackend { | |
| 869 | 695 | ||
| 870 | 696 | fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> { | |
| 871 | 697 | use audiofiles_core::analysis::{exemplar, trained_head}; | |
| @@ -1031,6 +857,9 @@ impl Backend for DirectBackend { | |||
| 1031 | 857 | let db = self.db.lock(); | |
| 1032 | 858 | Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash)) | |
| 1033 | 859 | } | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | impl SimilarityBackend for DirectBackend { | |
| 1034 | 863 | ||
| 1035 | 864 | // --- Similarity --- | |
| 1036 | 865 | ||
| @@ -1068,6 +897,9 @@ impl Backend for DirectBackend { | |||
| 1068 | 897 | } | |
| 1069 | 898 | Ok(()) | |
| 1070 | 899 | } | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | impl StoreBackend for DirectBackend { | |
| 1071 | 903 | ||
| 1072 | 904 | // --- Store --- | |
| 1073 | 905 | ||
| @@ -1175,6 +1007,9 @@ impl Backend for DirectBackend { | |||
| 1175 | 1007 | fn start_store_verify(&self) -> BackendResult<()> { | |
| 1176 | 1008 | self.loose_files_send(crate::loose_files_worker::LooseFilesCommand::VerifyStore) | |
| 1177 | 1009 | } | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | impl ExportBackend for DirectBackend { | |
| 1178 | 1013 | ||
| 1179 | 1014 | // --- Export --- | |
| 1180 | 1015 | ||
| @@ -1224,6 +1059,9 @@ impl Backend for DirectBackend { | |||
| 1224 | 1059 | Ok(None) | |
| 1225 | 1060 | } | |
| 1226 | 1061 | } | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | impl ForgeBackend for DirectBackend { | |
| 1227 | 1065 | ||
| 1228 | 1066 | // --- Sample Forge --- | |
| 1229 | 1067 | ||
| @@ -1422,6 +1260,9 @@ impl Backend for DirectBackend { | |||
| 1422 | 1260 | }); | |
| 1423 | 1261 | Ok(()) | |
| 1424 | 1262 | } | |
| 1263 | + | } | |
| 1264 | + | ||
| 1265 | + | impl ConfigBackend for DirectBackend { | |
| 1425 | 1266 | ||
| 1426 | 1267 | // --- Config --- | |
| 1427 | 1268 | ||
| @@ -1468,6 +1309,9 @@ impl Backend for DirectBackend { | |||
| 1468 | 1309 | let db = self.db.lock(); | |
| 1469 | 1310 | Ok(vfs::get_vfs_sync_files(&db, id)?) | |
| 1470 | 1311 | } | |
| 1312 | + | } | |
| 1313 | + | ||
| 1314 | + | impl OperationsBackend for DirectBackend { | |
| 1471 | 1315 | ||
| 1472 | 1316 | // --- VFS Mirror --- | |
| 1473 | 1317 | ||
| @@ -2054,6 +1898,8 @@ impl Backend for DirectBackend { | |||
| 2054 | 1898 | } | |
| 2055 | 1899 | } | |
| 2056 | 1900 | ||
| 1901 | + | impl Backend for DirectBackend {} | |
| 1902 | + | ||
| 2057 | 1903 | #[cfg(test)] | |
| 2058 | 1904 | mod tests { | |
| 2059 | 1905 | use super::*; |
| @@ -0,0 +1,100 @@ | |||
| 1 | + | //! Off-thread Sample Forge import plumbing: the in-flight forge context, the | |
| 2 | + | //! GUI-ready outcome, and the WAL-connection import jobs. Driven by the | |
| 3 | + | //! `ForgeBackend` impl in direct.rs; split out to keep that impl delegation-only. | |
| 4 | + | ||
| 5 | + | use std::path::Path; | |
| 6 | + | ||
| 7 | + | use audiofiles_core::db::Database; | |
| 8 | + | use audiofiles_core::store::SampleStore; | |
| 9 | + | use audiofiles_core::{NodeId, VfsId}; | |
| 10 | + | ||
| 11 | + | /// Import context for the in-flight forge operation. The worker does the CPU | |
| 12 | + | /// work (decode/slice/conform/encode → temp files); this carries the VFS | |
| 13 | + | /// destination so the GUI thread can import the staged files when the worker | |
| 14 | + | /// signals completion. | |
| 15 | + | pub(super) enum ForgePending { | |
| 16 | + | Chop { | |
| 17 | + | vfs_id: VfsId, | |
| 18 | + | parent_id: Option<NodeId>, | |
| 19 | + | }, | |
| 20 | + | Conform { | |
| 21 | + | vfs_id: VfsId, | |
| 22 | + | parent_id: Option<NodeId>, | |
| 23 | + | sample_rate: u32, | |
| 24 | + | bit_depth: u16, | |
| 25 | + | }, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// GUI-ready result of an off-thread forge import job. The import (blob copy + DB | |
| 29 | + | /// writes) runs on its own WAL connection so it never holds the GUI's `db.lock()`; | |
| 30 | + | /// `poll_workers` drains this and emits the matching `BackendEvent` on a later frame. | |
| 31 | + | pub(super) enum ForgeImportOutcome { | |
| 32 | + | Chop { slice_count: usize }, | |
| 33 | + | Conform { | |
| 34 | + | sample_rate: u32, | |
| 35 | + | bit_depth: u16, | |
| 36 | + | overshoot: Option<super::ForgeOvershoot>, | |
| 37 | + | }, | |
| 38 | + | Error { error: String }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Open a fresh WAL connection + store and import chop slices. Runs on a job | |
| 42 | + | /// thread (the established per-worker connection pattern) so the GUI's db.lock() | |
| 43 | + | /// is never held during the blob copies + row writes. | |
| 44 | + | pub(super) fn forge_import_chop( | |
| 45 | + | data_dir: &Path, | |
| 46 | + | root: &Path, | |
| 47 | + | vfs_id: VfsId, | |
| 48 | + | parent_id: Option<NodeId>, | |
| 49 | + | staging: audiofiles_core::forge::ChopStaging, | |
| 50 | + | ) -> ForgeImportOutcome { | |
| 51 | + | let db = match Database::open(data_dir.join("audiofiles.db")) { | |
| 52 | + | Ok(d) => d, | |
| 53 | + | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 54 | + | }; | |
| 55 | + | let store = match SampleStore::new(root) { | |
| 56 | + | Ok(s) => s, | |
| 57 | + | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 58 | + | }; | |
| 59 | + | match audiofiles_core::forge::import_chop(&store, &db, vfs_id, parent_id, staging) { | |
| 60 | + | Ok(r) => ForgeImportOutcome::Chop { slice_count: r.slice_count }, | |
| 61 | + | Err(e) => ForgeImportOutcome::Error { error: e.to_string() }, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Off-thread counterpart for conform import (see [`forge_import_chop`]). Maps the | |
| 66 | + | /// core overshoot result into the GUI-facing `ForgeOvershoot` here so the outcome | |
| 67 | + | /// is ready to emit. | |
| 68 | + | #[allow(clippy::too_many_arguments)] | |
| 69 | + | pub(super) fn forge_import_conform( | |
| 70 | + | data_dir: &Path, | |
| 71 | + | root: &Path, | |
| 72 | + | vfs_id: VfsId, | |
| 73 | + | parent_id: Option<NodeId>, | |
| 74 | + | sample_rate: u32, | |
| 75 | + | bit_depth: u16, | |
| 76 | + | staging: audiofiles_core::forge::ConformStaging, | |
| 77 | + | ) -> ForgeImportOutcome { | |
| 78 | + | let db = match Database::open(data_dir.join("audiofiles.db")) { | |
| 79 | + | Ok(d) => d, | |
| 80 | + | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 81 | + | }; | |
| 82 | + | let store = match SampleStore::new(root) { | |
| 83 | + | Ok(s) => s, | |
| 84 | + | Err(e) => return ForgeImportOutcome::Error { error: e.to_string() }, | |
| 85 | + | }; | |
| 86 | + | match audiofiles_core::forge::import_conform(&store, &db, vfs_id, parent_id, staging) { | |
| 87 | + | Ok(r) => { | |
| 88 | + | let overshoot = r.overshoot.map(|o| match o.action { | |
| 89 | + | audiofiles_core::forge::OvershootAction::Trimmed { gain_db } => { | |
| 90 | + | super::ForgeOvershoot::Trimmed { peak_dbfs: o.peak_dbfs, gain_db } | |
| 91 | + | } | |
| 92 | + | audiofiles_core::forge::OvershootAction::Flagged => { | |
| 93 | + | super::ForgeOvershoot::Flagged { peak_dbfs: o.peak_dbfs } | |
| 94 | + | } | |
| 95 | + | }); | |
| 96 | + | ForgeImportOutcome::Conform { sample_rate, bit_depth, overshoot } | |
| 97 | + | } | |
| 98 | + | Err(e) => ForgeImportOutcome::Error { error: e.to_string() }, | |
| 99 | + | } | |
| 100 | + | } |
| @@ -9,6 +9,8 @@ | |||
| 9 | 9 | ||
| 10 | 10 | pub mod direct; | |
| 11 | 11 | pub mod search_worker; | |
| 12 | + | mod forge; | |
| 13 | + | mod sample_info; | |
| 12 | 14 | ||
| 13 | 15 | use std::path::{Path, PathBuf}; | |
| 14 | 16 | ||
| @@ -278,7 +280,8 @@ pub enum ClassifierJobResult { | |||
| 278 | 280 | /// | |
| 279 | 281 | /// Every method is synchronous and blocking. The trait is `Send + Sync` so it | |
| 280 | 282 | /// can live inside `BrowserState` (which must be Send + Sync for nih-plug). | |
| 281 | - | pub trait Backend: Send + Sync { | |
| 283 | + | /// Virtual-filesystem navigation: vaults, directories, and sample links. | |
| 284 | + | pub trait VfsBackend { | |
| 282 | 285 | // --- VFS --- | |
| 283 | 286 | ||
| 284 | 287 | /// List all VFS roots, ordered alphabetically. | |
| @@ -354,6 +357,10 @@ pub trait Backend: Send + Sync { | |||
| 354 | 357 | vfs_id: VfsId, | |
| 355 | 358 | hashes: &[&str], | |
| 356 | 359 | ) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// Sample tagging: per-sample and bulk tag mutation and lookup. | |
| 363 | + | pub trait TagBackend { | |
| 357 | 364 | ||
| 358 | 365 | // --- Tags --- | |
| 359 | 366 | ||
| @@ -383,6 +390,10 @@ pub trait Backend: Send + Sync { | |||
| 383 | 390 | ||
| 384 | 391 | /// Remove a tag from every sample that carries it. Returns count of samples affected. | |
| 385 | 392 | fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize>; | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /// Text/filter search over the library. | |
| 396 | + | pub trait SearchBackend { | |
| 386 | 397 | ||
| 387 | 398 | // --- Search --- | |
| 388 | 399 | ||
| @@ -396,6 +407,10 @@ pub trait Backend: Send + Sync { | |||
| 396 | 407 | ||
| 397 | 408 | /// Search globally across all VFS roots. | |
| 398 | 409 | fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// Collections and smart (dynamic) folders. | |
| 413 | + | pub trait CollectionBackend { | |
| 399 | 414 | ||
| 400 | 415 | // --- Smart folders --- | |
| 401 | 416 | ||
| @@ -427,6 +442,10 @@ pub trait Backend: Send + Sync { | |||
| 427 | 442 | ||
| 428 | 443 | /// Get all collections containing a given sample. | |
| 429 | 444 | fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>>; | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | /// Feature analysis: read/persist results and rule application over them. | |
| 448 | + | pub trait AnalysisBackend { | |
| 430 | 449 | ||
| 431 | 450 | // --- Analysis --- | |
| 432 | 451 | ||
| @@ -450,6 +469,10 @@ pub trait Backend: Send + Sync { | |||
| 450 | 469 | /// Hashes (+ extension) of samples lacking a current-version feature vector | |
| 451 | 470 | /// (the feature-backfill work-list). | |
| 452 | 471 | fn samples_needing_features(&self) -> BackendResult<Vec<(String, String)>>; | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | /// Tag rules (classifier Layer A). | |
| 475 | + | pub trait RulesBackend { | |
| 453 | 476 | ||
| 454 | 477 | // --- Tag rules (classifier Layer A) --- | |
| 455 | 478 | ||
| @@ -483,6 +506,10 @@ pub trait Backend: Send + Sync { | |||
| 483 | 506 | &self, | |
| 484 | 507 | hash: &str, | |
| 485 | 508 | ) -> BackendResult<Vec<(String, String, Option<String>)>>; | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | /// Auto-tagging (Layer B): k-NN suggestions, thresholds, the trained head, .afcl sharing, clustering, and harvesting. | |
| 512 | + | pub trait ClassifierBackend { | |
| 486 | 513 | ||
| 487 | 514 | // --- Auto-tagging: k-NN suggestions, thresholds, bootstrap (Layer B) --- | |
| 488 | 515 | ||
| @@ -582,6 +609,10 @@ pub trait Backend: Send + Sync { | |||
| 582 | 609 | ||
| 583 | 610 | /// Get waveform display data for a sample. | |
| 584 | 611 | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>; | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | /// Similarity and near-duplicate search. | |
| 615 | + | pub trait SimilarityBackend { | |
| 585 | 616 | ||
| 586 | 617 | // --- Similarity --- | |
| 587 | 618 | ||
| @@ -593,6 +624,10 @@ pub trait Backend: Send + Sync { | |||
| 593 | 624 | /// Start a near-duplicate search for `hash` (fingerprint comparison). Results | |
| 594 | 625 | /// arrive via [`BackendEvent::NearDuplicateResults`] (or `SearchError`). | |
| 595 | 626 | fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()>; | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | /// Content-addressed store: import, sample paths, trash, and loose-file maintenance. | |
| 630 | + | pub trait StoreBackend { | |
| 596 | 631 | ||
| 597 | 632 | // --- Store --- | |
| 598 | 633 | ||
| @@ -680,6 +715,10 @@ pub trait Backend: Send + Sync { | |||
| 680 | 715 | /// `BackendEvent::StoreIntegrity`. Off the GUI thread because it re-hashes | |
| 681 | 716 | /// the whole managed library. | |
| 682 | 717 | fn start_store_verify(&self) -> BackendResult<()>; | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | /// Export item collection and device profiles. | |
| 721 | + | pub trait ExportBackend { | |
| 683 | 722 | ||
| 684 | 723 | // --- Export --- | |
| 685 | 724 | ||
| @@ -706,6 +745,10 @@ pub trait Backend: Send + Sync { | |||
| 706 | 745 | profile_name: &str, | |
| 707 | 746 | source_rate: u32, | |
| 708 | 747 | ) -> BackendResult<Option<ConformTarget>>; | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | /// Sample Forge: chop and conform. | |
| 751 | + | pub trait ForgeBackend { | |
| 709 | 752 | ||
| 710 | 753 | // --- Sample Forge --- | |
| 711 | 754 | ||
| @@ -767,6 +810,10 @@ pub trait Backend: Send + Sync { | |||
| 767 | 810 | parent_id: Option<NodeId>, | |
| 768 | 811 | target: &ConformTarget, | |
| 769 | 812 | ) -> BackendResult<()>; | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | /// App config key/value and per-VFS sync toggles. | |
| 816 | + | pub trait ConfigBackend { | |
| 770 | 817 | ||
| 771 | 818 | // --- Config --- | |
| 772 | 819 | ||
| @@ -784,6 +831,10 @@ pub trait Backend: Send + Sync { | |||
| 784 | 831 | ||
| 785 | 832 | /// Get whether a VFS has audio file blob syncing enabled. | |
| 786 | 833 | fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool>; | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | /// VFS mirror sync plus the long-running background workers (import/analysis/export/edit/cleanup), edit history, storage stats, and event polling. | |
| 837 | + | pub trait OperationsBackend { | |
| 787 | 838 | ||
| 788 | 839 | // --- VFS Mirror --- | |
| 789 | 840 | ||
| @@ -873,6 +924,28 @@ pub trait Backend: Send + Sync { | |||
| 873 | 924 | fn poll_events(&self) -> Vec<BackendEvent>; | |
| 874 | 925 | } | |
| 875 | 926 | ||
| 927 | + | /// The full backend surface. A set of capability supertraits so a | |
| 928 | + | /// `dyn Backend` still exposes every method while each capability can be | |
| 929 | + | /// reasoned about (and implemented) on its own. | |
| 930 | + | pub trait Backend: | |
| 931 | + | VfsBackend | |
| 932 | + | + TagBackend | |
| 933 | + | + SearchBackend | |
| 934 | + | + CollectionBackend | |
| 935 | + | + AnalysisBackend | |
| 936 | + | + RulesBackend | |
| 937 | + | + ClassifierBackend | |
| 938 | + | + SimilarityBackend | |
| 939 | + | + StoreBackend | |
| 940 | + | + ExportBackend | |
| 941 | + | + ForgeBackend | |
| 942 | + | + ConfigBackend | |
| 943 | + | + OperationsBackend | |
| 944 | + | + Send | |
| 945 | + | + Sync | |
| 946 | + | { | |
| 947 | + | } | |
| 948 | + | ||
| 876 | 949 | #[cfg(test)] | |
| 877 | 950 | mod tests { | |
| 878 | 951 | use super::*; |
| @@ -0,0 +1,109 @@ | |||
| 1 | + | //! Export sample-info helpers: assemble a `RhaiSampleInfo` for device-profile | |
| 2 | + | //! conform and probe bit depth from a file header. Split out of direct.rs. | |
| 3 | + | ||
| 4 | + | #[cfg(feature = "device-profiles")] | |
| 5 | + | use audiofiles_core::export::ExportItem; | |
| 6 | + | ||
| 7 | + | /// Build a RhaiSampleInfo from an ExportItem and database lookups. | |
| 8 | + | #[cfg(feature = "device-profiles")] | |
| 9 | + | pub(super) fn build_sample_info( | |
| 10 | + | db: &audiofiles_core::db::Database, | |
| 11 | + | store: &audiofiles_core::store::SampleStore, | |
| 12 | + | item: &ExportItem, | |
| 13 | + | ) -> audiofiles_rhai::types::RhaiSampleInfo { | |
| 14 | + | // Query audio_analysis for sample_rate and channels. Read the integers as | |
| 15 | + | // i64 so a valid-but-out-of-range stored value isn't reported as a query | |
| 16 | + | // error and lumped in with the legitimate "no analysis row yet" case; range | |
| 17 | + | // problems are surfaced explicitly below instead. Only QueryReturnedNoRows | |
| 18 | + | // is a silent (0,0) fallback — a genuine read error or a corrupt value | |
| 19 | + | // (negative / oversized rate or channels) is logged so a device profile | |
| 20 | + | // can't silently drop the sample on mystery zeros. | |
| 21 | + | let (sample_rate, channels, duration) = db | |
| 22 | + | .conn() | |
| 23 | + | .query_row( | |
| 24 | + | "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1", | |
| 25 | + | [&item.hash], | |
| 26 | + | |row| { | |
| 27 | + | Ok(( | |
| 28 | + | row.get::<_, i64>(0)?, | |
| 29 | + | row.get::<_, i64>(1)?, | |
| 30 | + | row.get::<_, f64>(2)?, | |
| 31 | + | )) | |
| 32 | + | }, | |
| 33 | + | ) | |
| 34 | + | .map(|(sr, ch, dur)| { | |
| 35 | + | let sample_rate = u32::try_from(sr).unwrap_or_else(|_| { | |
| 36 | + | tracing::warn!("Corrupt sample_rate {sr} for {}", &item.hash[..8]); | |
| 37 | + | 0 | |
| 38 | + | }); | |
| 39 | + | let channels = u16::try_from(ch).unwrap_or_else(|_| { | |
| 40 | + | tracing::warn!("Corrupt channels {ch} for {}", &item.hash[..8]); | |
| 41 | + | 0 | |
| 42 | + | }); | |
| 43 | + | (sample_rate, channels, dur) | |
| 44 | + | }) | |
| 45 | + | .unwrap_or_else(|e| { | |
| 46 | + | if !matches!(e, rusqlite::Error::QueryReturnedNoRows) { | |
| 47 | + | tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]); | |
| 48 | + | } | |
| 49 | + | (0, 0, item.duration.unwrap_or(0.0)) | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | // Query samples for file_size | |
| 53 | + | let file_size = db | |
| 54 | + | .conn() | |
| 55 | + | .query_row( | |
| 56 | + | "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL", | |
| 57 | + | [&item.hash], | |
| 58 | + | |row| row.get::<_, u64>(0), | |
| 59 | + | ) | |
| 60 | + | .unwrap_or_else(|_| { | |
| 61 | + | // Fallback: read from store | |
| 62 | + | store | |
| 63 | + | .sample_path(&item.hash, &item.ext) | |
| 64 | + | .ok() | |
| 65 | + | .and_then(|p| std::fs::metadata(p).ok()) | |
| 66 | + | .map(|m| m.len()) | |
| 67 | + | .unwrap_or(0) | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | // bit_depth is filled by the caller in a parallel pass off the DB lock — see | |
| 71 | + | // resolve_device_profile. Probing it here would open a file per item while | |
| 72 | + | // holding the DB lock on the GUI thread. | |
| 73 | + | audiofiles_rhai::types::RhaiSampleInfo { | |
| 74 | + | hash: item.hash.to_string(), | |
| 75 | + | name: item.name.clone(), | |
| 76 | + | extension: item.ext.clone(), | |
| 77 | + | sample_rate, | |
| 78 | + | bit_depth: 0, | |
| 79 | + | channels, | |
| 80 | + | duration, | |
| 81 | + | file_size, | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Probe bit depth from a WAV/AIFF file header without full decode. | |
| 86 | + | #[cfg(feature = "device-profiles")] | |
| 87 | + | pub(super) fn probe_bit_depth(path: &std::path::Path) -> Option<u16> { | |
| 88 | + | // Try hound first (WAV) | |
| 89 | + | if let Ok(reader) = hound::WavReader::open(path) { | |
| 90 | + | return Some(reader.spec().bits_per_sample); | |
| 91 | + | } | |
| 92 | + | // Try symphonia for AIFF/other formats | |
| 93 | + | let file = std::fs::File::open(path).ok()?; | |
| 94 | + | let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default()); | |
| 95 | + | let mut hint = symphonia::core::probe::Hint::new(); | |
| 96 | + | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { | |
| 97 | + | hint.with_extension(ext); | |
| 98 | + | } | |
| 99 | + | let probed = symphonia::default::get_probe() | |
| 100 | + | .format( | |
| 101 | + | &hint, | |
| 102 | + | mss, | |
| 103 | + | &symphonia::core::formats::FormatOptions::default(), | |
| 104 | + | &symphonia::core::meta::MetadataOptions::default(), | |
| 105 | + | ) | |
| 106 | + | .ok()?; | |
| 107 | + | let track = probed.format.default_track()?; | |
| 108 | + | track.codec_params.bits_per_sample.map(|b| b as u16) | |
| 109 | + | } |