max / audiofiles
56 files changed,
+4284 insertions,
-2065 deletions
| @@ -13,6 +13,7 @@ audiofiles_data/ | |||
| 13 | 13 | ||
| 14 | 14 | # Sample data (test files, not source) | |
| 15 | 15 | drums/ | |
| 16 | + | samples/ | |
| 16 | 17 | ||
| 17 | 18 | # Downloaded fonts | |
| 18 | 19 | recursive fonts.085/ | |
| @@ -30,3 +31,4 @@ recursive fonts.085/ | |||
| 30 | 31 | *.swp | |
| 31 | 32 | *.swo | |
| 32 | 33 | dist/*.exe | |
| 34 | + | dist/*.msi |
| @@ -408,6 +408,16 @@ dependencies = [ | |||
| 408 | 408 | ] | |
| 409 | 409 | ||
| 410 | 410 | [[package]] | |
| 411 | + | name = "audiofiles-bench" | |
| 412 | + | version = "0.3.2" | |
| 413 | + | dependencies = [ | |
| 414 | + | "audiofiles-core", | |
| 415 | + | "rayon", | |
| 416 | + | "serde", | |
| 417 | + | "serde_json", | |
| 418 | + | ] | |
| 419 | + | ||
| 420 | + | [[package]] | |
| 411 | 421 | name = "audiofiles-browser" | |
| 412 | 422 | version = "0.3.2" | |
| 413 | 423 | dependencies = [ |
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | [workspace] | |
| 2 | - | members = ["crates/audiofiles-core", "crates/audiofiles-browser", "crates/audiofiles-app", "crates/audiofiles-sync", "crates/audiofiles-rhai", "crates/audiofiles-train"] | |
| 2 | + | members = ["crates/audiofiles-core", "crates/audiofiles-browser", "crates/audiofiles-app", "crates/audiofiles-sync", "crates/audiofiles-rhai", "crates/audiofiles-train", "crates/audiofiles-bench"] | |
| 3 | 3 | default-members = ["crates/audiofiles-core", "crates/audiofiles-browser", "crates/audiofiles-app", "crates/audiofiles-sync", "crates/audiofiles-rhai"] | |
| 4 | 4 | resolver = "2" | |
| 5 | 5 |
| @@ -14,40 +14,87 @@ No platform-specific audio libraries are required. Audio decoding uses [Symphoni | |||
| 14 | 14 | # Standalone app | |
| 15 | 15 | cargo run -p audiofiles-app | |
| 16 | 16 | ||
| 17 | - | # Standalone app — import a folder on launch | |
| 17 | + | # Standalone app -- import a folder on launch | |
| 18 | 18 | cargo run -p audiofiles-app -- /path/to/samples | |
| 19 | 19 | ||
| 20 | 20 | # Run all workspace tests | |
| 21 | 21 | cargo test --workspace | |
| 22 | + | ||
| 23 | + | # Train the ML classifier (developers only) | |
| 24 | + | cargo run -p audiofiles-train -- /path/to/training-data | |
| 22 | 25 | ``` | |
| 23 | 26 | ||
| 24 | 27 | ## Workspace Architecture | |
| 25 | 28 | ||
| 26 | - | Five crates: | |
| 29 | + | Six crates: | |
| 27 | 30 | ||
| 28 | 31 | | Crate | Path | Role | | |
| 29 | 32 | |-------|------|------| | |
| 30 | - | | `audiofiles-core` | `crates/audiofiles-core/` | Domain library. [SQLite](https://sqlite.org/) database, content-addressed store (SHA-256), audio decoding ([Symphonia](https://github.com/pdeljanov/Symphonia)), analysis pipeline (loudness, BPM, key, spectral, classification), VFS, tag system. | | |
| 31 | - | | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared [egui](https://github.com/emilk/egui) UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export, themes. | | |
| 32 | - | | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via [eframe](https://github.com/emilk/egui/tree/master/crates/eframe). System audio output ([cpal](https://github.com/RustAudio/cpal)), drag-and-drop import, native drag-out to Finder/DAWs, CLI argument import, OTA updates. | | |
| 33 | - | | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync integration via [SyncKit](https://makenot.work). Pushes/pulls sample metadata, tags, and VFS structure across devices. E2E encrypted. | | |
| 34 | - | | `audiofiles-rhai` | `crates/audiofiles-rhai/` | [Rhai](https://rhai.rs/) scripting engine for device export profiles. Transforms sample metadata and file layout for 14 hardware samplers. | | |
| 33 | + | | `audiofiles-core` | `crates/audiofiles-core/` | Domain library. SQLite database, content-addressed store (SHA-256), audio decoding (Symphonia), analysis pipeline (loudness, BPM, key, spectral, classification), VFS, tag system, VP-tree similarity index. | | |
| 34 | + | | `audiofiles-browser` | `crates/audiofiles-browser/` | Shared egui UI. File list, detail panel, waveform display, search/filter, import wizard, analysis progress, export, themes. | | |
| 35 | + | | `audiofiles-app` | `crates/audiofiles-app/` | Standalone desktop app via eframe. System audio (cpal), drag-and-drop import, native drag-out to Finder/DAWs, system tray, CLI import, OTA updates. | | |
| 36 | + | | `audiofiles-sync` | `crates/audiofiles-sync/` | Cloud sync via SyncKit. Pushes/pulls sample metadata, tags, and VFS structure across devices. E2E encrypted. | | |
| 37 | + | | `audiofiles-rhai` | `crates/audiofiles-rhai/` | Rhai scripting engine for device export profiles. Transforms sample metadata and file layout for hardware samplers. | | |
| 38 | + | | `audiofiles-train` | `crates/audiofiles-train/` | ML classifier training binary. Builds the random forest model from labeled sample data. Not shipped in the app. | | |
| 39 | + | ||
| 40 | + | Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai` and `audiofiles-sync` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-app` depends on browser and core. `audiofiles-train` depends on core only. | |
| 35 | 41 | ||
| 36 | - | Dependency flow: `audiofiles-core` is the leaf -> `audiofiles-rhai` and `audiofiles-sync` depend on core -> `audiofiles-browser` depends on core, sync, and rhai -> `audiofiles-app` depends on browser and core. | |
| 42 | + | Shared libraries from `../Shared/`: [theme-common](../Shared/theme-common/) (theme loading), [synckit-client](../Shared/synckit-client/) (cloud sync SDK). | |
| 37 | 43 | ||
| 38 | 44 | ## Features | |
| 39 | 45 | ||
| 46 | + | ### Storage & Organization | |
| 40 | 47 | - **Content-addressed storage** -- samples stored by SHA-256 hash, automatic deduplication | |
| 41 | 48 | - **Virtual file system** -- organize samples in virtual directories independent of disk location, multiple VFS roots | |
| 42 | - | - **Analysis pipeline** -- loudness (peak/RMS/LUFS), BPM detection, key detection, spectral analysis, loop detection, classification into 12 categories | |
| 43 | 49 | - **Tag system** -- hierarchical dot-notation tags with auto-suggestions from analysis results | |
| 44 | - | - **Search and filtering** -- text search, BPM/duration ranges, key selector, classification filters, tag prefix matching, smart folders | |
| 45 | - | - **[Rhai](https://rhai.rs/) export engine** -- scriptable device profiles for exporting to 14 hardware samplers (SP-404, Digitakt, MPC, Deluge, OP-1, etc.) | |
| 46 | - | - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via [SyncKit](https://makenot.work) (E2E encrypted, [ChaCha20-Poly1305](https://docs.rs/chacha20poly1305) + [Argon2](https://docs.rs/argon2)) | |
| 47 | - | - **Native drag-out** -- drag samples from the file list directly to Finder, Desktop, or any DAW | |
| 50 | + | - **Smart folders** -- saved filter queries that update dynamically | |
| 51 | + | - **Collections** -- cross-VFS sample groupings | |
| 52 | + | ||
| 53 | + | ### Audio Analysis | |
| 54 | + | - **Analysis pipeline** -- loudness (peak/RMS/LUFS), BPM detection, key detection, spectral analysis | |
| 55 | + | - **ML classification** -- two-layer system: rule-based broad categories, then 200-tree random forest for drum sub-classification (94.4% accuracy on 4,343 samples) | |
| 56 | + | - **Loop detection** -- identifies seamless loops via amplitude envelope analysis | |
| 57 | + | - **Similarity search** -- VP-tree indexed fingerprinting for finding similar and duplicate samples (O(log n) lookup) | |
| 58 | + | - **Waveform display** -- pre-computed peak data with click-to-seek playback | |
| 59 | + | ||
| 60 | + | ### Search & Filtering | |
| 61 | + | - **Text search** -- FTS5 indexed across filenames, tags, and metadata | |
| 62 | + | - **Parameter filters** -- BPM range, duration range, key selector, classification category | |
| 63 | + | - **Tag prefix matching** -- type a tag prefix to filter by hierarchy | |
| 64 | + | ||
| 65 | + | ### Editing | |
| 66 | + | - **Destructive editing** -- trim, fade in/out, normalize, reverse, gain adjust | |
| 67 | + | - **Edit history** -- full undo/redo stack per sample | |
| 68 | + | - **Bulk operations** -- bulk delete, move, rename, tag across selections | |
| 69 | + | - **Rename engine** -- pattern-based renaming with tokens (name, bpm, key, index, etc.) | |
| 70 | + | ||
| 71 | + | ### Device Export | |
| 72 | + | - **Rhai export profiles** -- scriptable export for 14 hardware samplers: | |
| 73 | + | M8, Digitakt, Digitakt II, Octatrack, Model:Samples, SP-404 MKII, MPC, Polyend Tracker, Deluge, Blackbox, Volca Sample 2, OP-1, Circuit Rhythm, Maschine+ | |
| 74 | + | ||
| 75 | + | ### Playback & Integration | |
| 48 | 76 | - **MIDI instrument** -- chromatic and multi-sample playback modes with 8-voice polyphony and ADSR envelopes | |
| 49 | - | - **17 bundled themes** -- dark, light, and high-contrast variants in [TOML](https://toml.io/) format | |
| 50 | - | - **Audio formats** -- WAV, FLAC, MP3, OGG, AIFF | |
| 77 | + | - **Native drag-out** -- drag samples from the file list directly to Finder, Desktop, or any DAW (macOS + Windows) | |
| 78 | + | - **System tray** -- minimize to tray, quick access | |
| 79 | + | ||
| 80 | + | ### Infrastructure | |
| 81 | + | - **Cloud sync** -- cross-device sync of metadata, tags, and VFS via SyncKit (E2E encrypted, ChaCha20-Poly1305 + Argon2) | |
| 82 | + | - **OTA updates** -- background update checker with consent dialog | |
| 83 | + | - **17 bundled themes** -- dark, light, and high-contrast variants in TOML format | |
| 84 | + | - **Audio formats** -- WAV, FLAC, MP3, OGG, AIFF (via Symphonia, pure Rust) | |
| 85 | + | - **Platforms** -- macOS, Windows, Linux (standalone, no backend required) | |
| 86 | + | ||
| 87 | + | ## Key Paths | |
| 88 | + | ||
| 89 | + | | What | Where | | |
| 90 | + | |------|-------| | |
| 91 | + | | Domain library | `crates/audiofiles-core/src/` | | |
| 92 | + | | ML classifier model | `crates/audiofiles-core/models/layer2_drum.json` | | |
| 93 | + | | Training binary | `crates/audiofiles-train/` | | |
| 94 | + | | UI components | `crates/audiofiles-browser/src/` | | |
| 95 | + | | Desktop app shell | `crates/audiofiles-app/src/` | | |
| 96 | + | | Device export profiles | `plugins/bundled/` | | |
| 97 | + | | Architecture | `docs/architecture.md` | | |
| 51 | 98 | ||
| 52 | 99 | ## License | |
| 53 | 100 |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-app" | |
| 3 | - | version = "0.3.2" | |
| 3 | + | version = "0.3.3" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] |
| @@ -30,8 +30,6 @@ pub type ActivationResult = Arc<Mutex<Option<Result<(), String>>>>; | |||
| 30 | 30 | ||
| 31 | 31 | // ── API request/response types ── | |
| 32 | 32 | ||
| 33 | - | // TODO: remove allow(dead_code) when server validation is re-enabled. | |
| 34 | - | #[allow(dead_code)] | |
| 35 | 33 | #[derive(Serialize)] | |
| 36 | 34 | struct ValidateRequest<'a> { | |
| 37 | 35 | key: &'a str, | |
| @@ -39,7 +37,6 @@ struct ValidateRequest<'a> { | |||
| 39 | 37 | label: Option<&'a str>, | |
| 40 | 38 | } | |
| 41 | 39 | ||
| 42 | - | #[allow(dead_code)] | |
| 43 | 40 | #[derive(Deserialize)] | |
| 44 | 41 | struct ValidateResponse { | |
| 45 | 42 | valid: bool, | |
| @@ -120,10 +117,43 @@ pub fn remove_license(data_dir: &Path) -> io::Result<()> { | |||
| 120 | 117 | ||
| 121 | 118 | /// Activate a license key against the MNW API. | |
| 122 | 119 | /// | |
| 123 | - | /// TODO: re-enable server validation before public release. | |
| 124 | - | pub async fn activate_key(_server_url: &str, _key: &str, _machine_id: &str) -> Result<(), String> { | |
| 125 | - | tracing::warn!("License server check disabled — testing build"); | |
| 126 | - | Ok(()) | |
| 120 | + | /// Sends the key and machine ID to the server for validation. On success the | |
| 121 | + | /// server records an activation slot; on failure the returned error message | |
| 122 | + | /// is shown to the user. | |
| 123 | + | pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> { | |
| 124 | + | let client = reqwest::Client::builder() | |
| 125 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 126 | + | .build() | |
| 127 | + | .map_err(|e| format!("HTTP client error: {e}"))?; | |
| 128 | + | ||
| 129 | + | let url = format!("{server_url}/api/keys/validate"); | |
| 130 | + | let body = ValidateRequest { | |
| 131 | + | key, | |
| 132 | + | machine_id, | |
| 133 | + | label: None, | |
| 134 | + | }; | |
| 135 | + | ||
| 136 | + | let resp = client | |
| 137 | + | .post(&url) | |
| 138 | + | .json(&body) | |
| 139 | + | .send() | |
| 140 | + | .await | |
| 141 | + | .map_err(|e| format!("Network error: {e}"))?; | |
| 142 | + | ||
| 143 | + | if !resp.status().is_success() { | |
| 144 | + | return Err(format!("Server returned {}", resp.status())); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | let parsed: ValidateResponse = resp | |
| 148 | + | .json() | |
| 149 | + | .await | |
| 150 | + | .map_err(|e| format!("Invalid response: {e}"))?; | |
| 151 | + | ||
| 152 | + | if parsed.valid { | |
| 153 | + | Ok(()) | |
| 154 | + | } else { | |
| 155 | + | Err(parsed.error.unwrap_or_else(|| "Invalid license key".to_string())) | |
| 156 | + | } | |
| 127 | 157 | } | |
| 128 | 158 | ||
| 129 | 159 | /// Deactivate a license key (best-effort, fire-and-forget). |
| @@ -0,0 +1,14 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "audiofiles-bench" | |
| 3 | + | version = "0.3.3" | |
| 4 | + | edition.workspace = true | |
| 5 | + | ||
| 6 | + | [[bin]] | |
| 7 | + | name = "audiofiles-bench" | |
| 8 | + | path = "src/main.rs" | |
| 9 | + | ||
| 10 | + | [dependencies] | |
| 11 | + | audiofiles-core = { workspace = true, features = ["analysis"] } | |
| 12 | + | serde = { workspace = true } | |
| 13 | + | serde_json = { workspace = true } | |
| 14 | + | rayon = { workspace = true } |
| @@ -0,0 +1,686 @@ | |||
| 1 | + | //! Pipeline benchmark for audiofiles analysis. | |
| 2 | + | //! | |
| 3 | + | //! Measures per-stage timing, throughput, resource usage, and classification accuracy | |
| 4 | + | //! against labeled training data. | |
| 5 | + | //! | |
| 6 | + | //! Usage: `cargo run --release -p audiofiles-bench` | |
| 7 | + | ||
| 8 | + | use std::collections::HashMap; | |
| 9 | + | use std::path::{Path, PathBuf}; | |
| 10 | + | use std::time::Instant; | |
| 11 | + | ||
| 12 | + | use audiofiles_core::analysis::classify::{self, ClassifyInput, SampleClass}; | |
| 13 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 14 | + | use audiofiles_core::analysis::loudness; | |
| 15 | + | use audiofiles_core::analysis::{self, basic, bpm, decode, loop_detect, mfcc, spectral}; | |
| 16 | + | use audiofiles_core::fingerprint; | |
| 17 | + | use rayon::prelude::*; | |
| 18 | + | ||
| 19 | + | // ── Timing Helpers ── | |
| 20 | + | ||
| 21 | + | struct StageTiming { | |
| 22 | + | decode_ms: f64, | |
| 23 | + | loudness_ms: f64, | |
| 24 | + | spectral_ms: f64, | |
| 25 | + | mfcc_ms: f64, | |
| 26 | + | classify_ms: f64, | |
| 27 | + | bpm_key_ms: f64, | |
| 28 | + | loop_ms: f64, | |
| 29 | + | fingerprint_ms: f64, | |
| 30 | + | total_ms: f64, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | fn time_stages(path: &Path) -> Option<(StageTiming, f64, u32)> { | |
| 34 | + | let total_start = Instant::now(); | |
| 35 | + | ||
| 36 | + | // Decode | |
| 37 | + | let t = Instant::now(); | |
| 38 | + | let decoded = decode::decode_to_mono(path).ok()?; | |
| 39 | + | let decode_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 40 | + | ||
| 41 | + | let duration = decoded.duration; | |
| 42 | + | let sr = decoded.sample_rate; | |
| 43 | + | let max_secs = 30.0; | |
| 44 | + | let max_samples = (max_secs * sr as f64) as usize; | |
| 45 | + | let capped = &decoded.samples[..decoded.samples.len().min(max_samples)]; | |
| 46 | + | ||
| 47 | + | // Loudness | |
| 48 | + | let t = Instant::now(); | |
| 49 | + | let _ = basic::peak_db(&decoded.samples); | |
| 50 | + | let _ = basic::rms_db(&decoded.samples); | |
| 51 | + | let crest = basic::crest_factor(&decoded.samples); | |
| 52 | + | let attack = basic::attack_time(&decoded.samples, sr); | |
| 53 | + | let _ = loudness::measure_lufs(&decoded.samples, sr); | |
| 54 | + | let loudness_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 55 | + | ||
| 56 | + | // Spectral | |
| 57 | + | let t = Instant::now(); | |
| 58 | + | let (features, magnitude_frames) = | |
| 59 | + | spectral::compute_spectral_features_with_frames(capped, sr); | |
| 60 | + | let spectral_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 61 | + | ||
| 62 | + | // MFCC | |
| 63 | + | let t = Instant::now(); | |
| 64 | + | let mfcc_features = mfcc::compute_mfccs(&magnitude_frames, sr, 1024); | |
| 65 | + | let mfcc_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 66 | + | ||
| 67 | + | // Classify | |
| 68 | + | let t = Instant::now(); | |
| 69 | + | let input = ClassifyInput::with_mfccs(&features, duration, crest, attack, &mfcc_features); | |
| 70 | + | let _ = classify::classify_ml(&input); | |
| 71 | + | let classify_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 72 | + | ||
| 73 | + | // BPM + Key | |
| 74 | + | let t = Instant::now(); | |
| 75 | + | let bpm_result = bpm::detect_bpm_key(capped, sr, 2.0); | |
| 76 | + | let bpm_key_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 77 | + | ||
| 78 | + | // Loop detect | |
| 79 | + | let t = Instant::now(); | |
| 80 | + | let _ = loop_detect::is_loop(&decoded.samples, sr, bpm_result.bpm); | |
| 81 | + | let loop_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 82 | + | ||
| 83 | + | // Fingerprint | |
| 84 | + | let t = Instant::now(); | |
| 85 | + | let _ = fingerprint::compute_envelope(&decoded.samples, sr); | |
| 86 | + | let fingerprint_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 87 | + | ||
| 88 | + | let total_ms = total_start.elapsed().as_secs_f64() * 1000.0; | |
| 89 | + | ||
| 90 | + | Some(( | |
| 91 | + | StageTiming { | |
| 92 | + | decode_ms, | |
| 93 | + | loudness_ms, | |
| 94 | + | spectral_ms, | |
| 95 | + | mfcc_ms, | |
| 96 | + | classify_ms, | |
| 97 | + | bpm_key_ms, | |
| 98 | + | loop_ms, | |
| 99 | + | fingerprint_ms, | |
| 100 | + | total_ms, | |
| 101 | + | }, | |
| 102 | + | duration, | |
| 103 | + | sr, | |
| 104 | + | )) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ── Classification Accuracy ── | |
| 108 | + | ||
| 109 | + | fn expected_class_from_dir(dir_name: &str) -> Option<SampleClass> { | |
| 110 | + | match dir_name { | |
| 111 | + | "kick" => Some(SampleClass::Kick), | |
| 112 | + | "snare" => Some(SampleClass::Snare), | |
| 113 | + | "hihat" => Some(SampleClass::HiHat), | |
| 114 | + | "cymbal" => Some(SampleClass::Cymbal), | |
| 115 | + | "clap" => Some(SampleClass::Clap), | |
| 116 | + | "tom" => Some(SampleClass::Tom), | |
| 117 | + | "percussion" => Some(SampleClass::Percussion), | |
| 118 | + | _ => None, | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// True if predicted is "correct enough": | |
| 123 | + | /// - Exact match, OR | |
| 124 | + | /// - Both are drum types (permissive: kick/snare/hihat/cymbal/percussion) | |
| 125 | + | fn is_drum_class(c: SampleClass) -> bool { | |
| 126 | + | matches!( | |
| 127 | + | c, | |
| 128 | + | SampleClass::Kick | |
| 129 | + | | SampleClass::Snare | |
| 130 | + | | SampleClass::HiHat | |
| 131 | + | | SampleClass::Cymbal | |
| 132 | + | | SampleClass::Clap | |
| 133 | + | | SampleClass::Tom | |
| 134 | + | | SampleClass::Percussion | |
| 135 | + | ) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | struct ClassifyResult { | |
| 139 | + | expected: SampleClass, | |
| 140 | + | predicted: SampleClass, | |
| 141 | + | confidence: f64, | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | fn classify_file(path: &Path) -> Option<(SampleClass, f64)> { | |
| 145 | + | let decoded = decode::decode_to_mono(path).ok()?; | |
| 146 | + | let sr = decoded.sample_rate; | |
| 147 | + | let max_samples = (30.0 * sr as f64) as usize; | |
| 148 | + | let capped = &decoded.samples[..decoded.samples.len().min(max_samples)]; | |
| 149 | + | ||
| 150 | + | let crest = basic::crest_factor(&decoded.samples); | |
| 151 | + | let attack = basic::attack_time(&decoded.samples, sr); | |
| 152 | + | let (features, magnitude_frames) = | |
| 153 | + | spectral::compute_spectral_features_with_frames(capped, sr); | |
| 154 | + | let mfcc_features = mfcc::compute_mfccs(&magnitude_frames, sr, 1024); | |
| 155 | + | let input = ClassifyInput::with_mfccs(&features, decoded.duration, crest, attack, &mfcc_features); | |
| 156 | + | let result = classify::classify_ml(&input); | |
| 157 | + | Some((result.class, result.confidence)) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // ── File Discovery ── | |
| 161 | + | ||
| 162 | + | fn audio_extensions() -> &'static [&'static str] { | |
| 163 | + | &[".wav", ".aif", ".aiff", ".mp3", ".ogg", ".flac"] | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn is_audio(name: &str) -> bool { | |
| 167 | + | let lower = name.to_lowercase(); | |
| 168 | + | audio_extensions().iter().any(|ext| lower.ends_with(ext)) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | fn collect_audio_files(dir: &Path, limit: Option<usize>) -> Vec<PathBuf> { | |
| 172 | + | let mut files: Vec<PathBuf> = Vec::new(); | |
| 173 | + | if !dir.exists() { | |
| 174 | + | return files; | |
| 175 | + | } | |
| 176 | + | for entry in walkdir(dir) { | |
| 177 | + | if let Some(lim) = limit { | |
| 178 | + | if files.len() >= lim { | |
| 179 | + | break; | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | files.push(entry); | |
| 183 | + | } | |
| 184 | + | files | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | fn walkdir(dir: &Path) -> Vec<PathBuf> { | |
| 188 | + | let mut out = Vec::new(); | |
| 189 | + | if let Ok(entries) = std::fs::read_dir(dir) { | |
| 190 | + | for entry in entries.flatten() { | |
| 191 | + | let path = entry.path(); | |
| 192 | + | if path.is_dir() { | |
| 193 | + | out.extend(walkdir(&path)); | |
| 194 | + | } else { | |
| 195 | + | let name = entry.file_name().to_string_lossy().to_string(); | |
| 196 | + | if is_audio(&name) { | |
| 197 | + | // Resolve symlinks | |
| 198 | + | let resolved = std::fs::read_link(&path).unwrap_or(path); | |
| 199 | + | if resolved.exists() { | |
| 200 | + | out.push(resolved); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | out | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // ── Report Formatting ── | |
| 210 | + | ||
| 211 | + | fn percentile(values: &mut [f64], p: f64) -> f64 { | |
| 212 | + | if values.is_empty() { | |
| 213 | + | return 0.0; | |
| 214 | + | } | |
| 215 | + | values.sort_by(|a, b| a.total_cmp(b)); | |
| 216 | + | let idx = (p / 100.0 * (values.len() - 1) as f64).round() as usize; | |
| 217 | + | values[idx.min(values.len() - 1)] | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | fn main() { | |
| 221 | + | let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 222 | + | .parent() | |
| 223 | + | .unwrap() | |
| 224 | + | .parent() | |
| 225 | + | .unwrap() | |
| 226 | + | .to_path_buf(); | |
| 227 | + | let samples_dir = project_root.join("samples"); | |
| 228 | + | let training_dir = samples_dir.join("training"); | |
| 229 | + | let test_suite_dir = samples_dir.join("test-suite"); | |
| 230 | + | ||
| 231 | + | println!("╔══════════════════════════════════════════════════════════════╗"); | |
| 232 | + | println!("║ AudioFiles Analysis Pipeline — Benchmark Report ║"); | |
| 233 | + | println!("╚══════════════════════════════════════════════════════════════╝"); | |
| 234 | + | println!(); | |
| 235 | + | ||
| 236 | + | // ───────────────────────────────────────────────────────────── | |
| 237 | + | // Section 1: Per-Stage Timing (representative sample set) | |
| 238 | + | // ───────────────────────────────────────────────────────────── | |
| 239 | + | println!("━━━ 1. PER-STAGE TIMING ━━━"); | |
| 240 | + | println!(); | |
| 241 | + | ||
| 242 | + | // Collect samples from different sources for timing | |
| 243 | + | let timing_sources: Vec<(&str, PathBuf, Option<usize>)> = vec![ | |
| 244 | + | ("drum one-shots (WAV)", training_dir.join("kick"), Some(200)), | |
| 245 | + | ("drum one-shots (WAV)", training_dir.join("snare"), Some(200)), | |
| 246 | + | ("drum one-shots (WAV)", training_dir.join("hihat"), Some(200)), | |
| 247 | + | ("philharmonia (MP3)", test_suite_dir.join("formats/mp3"), Some(50)), | |
| 248 | + | ("AIFF samples", test_suite_dir.join("formats/aiff"), Some(10)), | |
| 249 | + | ("FLAC samples", test_suite_dir.join("formats/flac"), Some(10)), | |
| 250 | + | ("ambient loops (WAV)", test_suite_dir.join("genres/ambient"), Some(50)), | |
| 251 | + | ("synth loops (WAV)", test_suite_dir.join("genres/synth"), Some(50)), | |
| 252 | + | ("guitar loops (WAV)", test_suite_dir.join("genres/guitar"), Some(50)), | |
| 253 | + | ]; | |
| 254 | + | ||
| 255 | + | let mut all_timings: Vec<(StageTiming, f64, u32, String)> = Vec::new(); | |
| 256 | + | ||
| 257 | + | for (label, dir, limit) in &timing_sources { | |
| 258 | + | let files = collect_audio_files(dir, *limit); | |
| 259 | + | if files.is_empty() { | |
| 260 | + | continue; | |
| 261 | + | } | |
| 262 | + | let results: Vec<_> = files | |
| 263 | + | .par_iter() | |
| 264 | + | .filter_map(|f| { | |
| 265 | + | time_stages(f).map(|(t, dur, sr)| (t, dur, sr, label.to_string())) | |
| 266 | + | }) | |
| 267 | + | .collect(); | |
| 268 | + | all_timings.extend(results); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | if all_timings.is_empty() { | |
| 272 | + | eprintln!("No files found for timing benchmarks!"); | |
| 273 | + | std::process::exit(1); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | let n = all_timings.len(); | |
| 277 | + | println!("Benchmarked {} files", n); | |
| 278 | + | println!(); | |
| 279 | + | ||
| 280 | + | // Aggregate per-stage | |
| 281 | + | let mut decode = Vec::new(); | |
| 282 | + | let mut loud = Vec::new(); | |
| 283 | + | let mut spec = Vec::new(); | |
| 284 | + | let mut mfcc_t = Vec::new(); | |
| 285 | + | let mut class = Vec::new(); | |
| 286 | + | let mut bpm_t = Vec::new(); | |
| 287 | + | let mut loop_t = Vec::new(); | |
| 288 | + | let mut fp_t = Vec::new(); | |
| 289 | + | let mut total = Vec::new(); | |
| 290 | + | let mut durations = Vec::new(); | |
| 291 | + | ||
| 292 | + | for (t, dur, _sr, _) in &all_timings { | |
| 293 | + | decode.push(t.decode_ms); | |
| 294 | + | loud.push(t.loudness_ms); | |
| 295 | + | spec.push(t.spectral_ms); | |
| 296 | + | mfcc_t.push(t.mfcc_ms); | |
| 297 | + | class.push(t.classify_ms); | |
| 298 | + | bpm_t.push(t.bpm_key_ms); | |
| 299 | + | loop_t.push(t.loop_ms); | |
| 300 | + | fp_t.push(t.fingerprint_ms); | |
| 301 | + | total.push(t.total_ms); | |
| 302 | + | durations.push(*dur); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | fn stats_line(name: &str, vals: &mut Vec<f64>) { | |
| 306 | + | let mean = vals.iter().sum::<f64>() / vals.len() as f64; | |
| 307 | + | let p50 = percentile(vals, 50.0); | |
| 308 | + | let p95 = percentile(vals, 95.0); | |
| 309 | + | let p99 = percentile(vals, 99.0); | |
| 310 | + | let max = percentile(vals, 100.0); | |
| 311 | + | println!( | |
| 312 | + | " {:<16} {:>8.2} {:>8.2} {:>8.2} {:>8.2} {:>8.2}", | |
| 313 | + | name, mean, p50, p95, p99, max | |
| 314 | + | ); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | println!(" {:<16} {:>8} {:>8} {:>8} {:>8} {:>8}", "Stage", "Mean", "P50", "P95", "P99", "Max"); | |
| 318 | + | println!(" {}", "─".repeat(58)); | |
| 319 | + | stats_line("Decode", &mut decode); | |
| 320 | + | stats_line("Loudness+LUFS", &mut loud); | |
| 321 | + | stats_line("Spectral/STFT", &mut spec); | |
| 322 | + | stats_line("MFCC", &mut mfcc_t); | |
| 323 | + | stats_line("Classify", &mut class); | |
| 324 | + | stats_line("BPM+Key", &mut bpm_t); | |
| 325 | + | stats_line("Loop Detect", &mut loop_t); | |
| 326 | + | stats_line("Fingerprint", &mut fp_t); | |
| 327 | + | println!(" {}", "─".repeat(58)); | |
| 328 | + | stats_line("TOTAL", &mut total); | |
| 329 | + | println!(); | |
| 330 | + | ||
| 331 | + | // Duration stats | |
| 332 | + | let avg_dur = durations.iter().sum::<f64>() / durations.len() as f64; | |
| 333 | + | let avg_total = total.iter().sum::<f64>() / total.len() as f64; | |
| 334 | + | let realtime_ratio = avg_dur * 1000.0 / avg_total; | |
| 335 | + | println!(" Avg sample duration: {:.2}s", avg_dur); | |
| 336 | + | println!(" Avg analysis time: {:.1}ms", avg_total); | |
| 337 | + | println!(" Real-time ratio: {:.0}× (analysis is {:.0}× faster than real-time)", realtime_ratio, realtime_ratio); | |
| 338 | + | println!(); | |
| 339 | + | ||
| 340 | + | // ───────────────────────────────────────────────────────────── | |
| 341 | + | // Section 2: Format-Specific Performance | |
| 342 | + | // ───────────────────────────────────────────────────────────── | |
| 343 | + | println!("━━━ 2. FORMAT-SPECIFIC DECODE PERFORMANCE ━━━"); | |
| 344 | + | println!(); | |
| 345 | + | ||
| 346 | + | let format_dirs: Vec<(&str, PathBuf)> = vec![ | |
| 347 | + | ("WAV", test_suite_dir.join("formats/wav")), | |
| 348 | + | ("AIFF", test_suite_dir.join("formats/aiff")), | |
| 349 | + | ("MP3", test_suite_dir.join("formats/mp3")), | |
| 350 | + | ("FLAC", test_suite_dir.join("formats/flac")), | |
| 351 | + | ]; | |
| 352 | + | ||
| 353 | + | println!(" {:<8} {:>6} {:>10} {:>10} {:>10}", "Format", "Files", "Mean(ms)", "P95(ms)", "Max(ms)"); | |
| 354 | + | println!(" {}", "─".repeat(50)); | |
| 355 | + | ||
| 356 | + | for (fmt, dir) in &format_dirs { | |
| 357 | + | let files = collect_audio_files(dir, Some(100)); | |
| 358 | + | if files.is_empty() { | |
| 359 | + | println!(" {:<8} {:>6} {:>10} {:>10} {:>10}", fmt, 0, "-", "-", "-"); | |
| 360 | + | continue; | |
| 361 | + | } | |
| 362 | + | let mut decode_times: Vec<f64> = files | |
| 363 | + | .par_iter() | |
| 364 | + | .filter_map(|f| { | |
| 365 | + | let t = Instant::now(); | |
| 366 | + | decode::decode_to_mono(f).ok()?; | |
| 367 | + | Some(t.elapsed().as_secs_f64() * 1000.0) | |
| 368 | + | }) | |
| 369 | + | .collect(); | |
| 370 | + | let count = decode_times.len(); | |
| 371 | + | let mean = decode_times.iter().sum::<f64>() / count as f64; | |
| 372 | + | let p95 = percentile(&mut decode_times, 95.0); | |
| 373 | + | let max = percentile(&mut decode_times, 100.0); | |
| 374 | + | println!(" {:<8} {:>6} {:>10.2} {:>10.2} {:>10.2}", fmt, count, mean, p95, max); | |
| 375 | + | } | |
| 376 | + | println!(); | |
| 377 | + | ||
| 378 | + | // ───────────────────────────────────────────────────────────── | |
| 379 | + | // Section 3: Throughput | |
| 380 | + | // ───────────────────────────────────────────────────────────── | |
| 381 | + | println!("━━━ 3. THROUGHPUT ━━━"); | |
| 382 | + | println!(); | |
| 383 | + | ||
| 384 | + | // Parallel full-pipeline throughput on 500 drum one-shots | |
| 385 | + | let throughput_files = collect_audio_files(&training_dir.join("kick"), Some(250)); | |
| 386 | + | let mut throughput_files_ext = throughput_files; | |
| 387 | + | throughput_files_ext.extend(collect_audio_files(&training_dir.join("snare"), Some(250))); | |
| 388 | + | ||
| 389 | + | let config = AnalysisConfig { | |
| 390 | + | loudness: true, | |
| 391 | + | spectral: true, | |
| 392 | + | bpm: true, | |
| 393 | + | key: true, | |
| 394 | + | loop_detect: true, | |
| 395 | + | classify: true, | |
| 396 | + | fingerprint: true, | |
| 397 | + | auto_suggest_tags: false, | |
| 398 | + | max_analysis_seconds: Some(30.0), | |
| 399 | + | smart_skip: false, | |
| 400 | + | }; | |
| 401 | + | ||
| 402 | + | let tp_count = throughput_files_ext.len(); | |
| 403 | + | let tp_start = Instant::now(); | |
| 404 | + | let tp_ok: usize = throughput_files_ext | |
| 405 | + | .par_iter() | |
| 406 | + | .filter(|f| analysis::analyze_sample("bench", f, &config).is_ok()) | |
| 407 | + | .count(); | |
| 408 | + | let tp_elapsed = tp_start.elapsed().as_secs_f64(); | |
| 409 | + | let tp_rate = tp_ok as f64 / tp_elapsed; | |
| 410 | + | ||
| 411 | + | println!(" Full pipeline (all stages, parallel):"); | |
| 412 | + | println!(" Files: {} ({} succeeded)", tp_count, tp_ok); | |
| 413 | + | println!(" Wall time: {:.1}s", tp_elapsed); | |
| 414 | + | println!(" Throughput: {:.1} files/sec", tp_rate); | |
| 415 | + | println!(" Avg/file: {:.1}ms", tp_elapsed * 1000.0 / tp_ok as f64); | |
| 416 | + | println!(); | |
| 417 | + | ||
| 418 | + | // Single-threaded throughput for comparison | |
| 419 | + | let st_files = collect_audio_files(&training_dir.join("kick"), Some(100)); | |
| 420 | + | let st_start = Instant::now(); | |
| 421 | + | let st_ok: usize = st_files | |
| 422 | + | .iter() | |
| 423 | + | .filter(|f| analysis::analyze_sample("bench", f, &config).is_ok()) | |
| 424 | + | .count(); | |
| 425 | + | let st_elapsed = st_start.elapsed().as_secs_f64(); | |
| 426 | + | let st_rate = st_ok as f64 / st_elapsed; | |
| 427 | + | ||
| 428 | + | println!(" Single-threaded comparison (100 files):"); | |
| 429 | + | println!(" Throughput: {:.1} files/sec", st_rate); | |
| 430 | + | println!(" Speedup from parallelism: {:.1}×", tp_rate / st_rate); | |
| 431 | + | println!(); | |
| 432 | + | ||
| 433 | + | // ───────────────────────────────────────────────────────────── | |
| 434 | + | // Section 4: Resource Usage | |
| 435 | + | // ───────────────────────────────────────────────────────────── | |
| 436 | + | println!("━━━ 4. RESOURCE USAGE ━━━"); | |
| 437 | + | println!(); | |
| 438 | + | ||
| 439 | + | // Memory estimation: decode a few files of varying duration and measure buffer sizes | |
| 440 | + | let mem_files: Vec<(&str, PathBuf)> = vec![ | |
| 441 | + | ("Short drum hit", training_dir.join("kick")), | |
| 442 | + | ("Medium loop", test_suite_dir.join("genres/ambient")), | |
| 443 | + | ]; | |
| 444 | + | ||
| 445 | + | println!(" Per-sample memory (mono f32 decode buffer):"); | |
| 446 | + | for (label, dir) in &mem_files { | |
| 447 | + | let files = collect_audio_files(dir, Some(10)); | |
| 448 | + | if files.is_empty() { | |
| 449 | + | continue; | |
| 450 | + | } | |
| 451 | + | let mut sizes: Vec<(f64, usize)> = Vec::new(); | |
| 452 | + | for f in &files { | |
| 453 | + | if let Ok(decoded) = decode::decode_to_mono(f) { | |
| 454 | + | let bytes = decoded.samples.len() * 4; // f32 = 4 bytes | |
| 455 | + | sizes.push((decoded.duration, bytes)); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | if !sizes.is_empty() { | |
| 459 | + | let avg_dur = sizes.iter().map(|(d, _)| d).sum::<f64>() / sizes.len() as f64; | |
| 460 | + | let avg_bytes = sizes.iter().map(|(_, b)| *b).sum::<usize>() / sizes.len(); | |
| 461 | + | let max_bytes = sizes.iter().map(|(_, b)| *b).max().unwrap_or(0); | |
| 462 | + | println!( | |
| 463 | + | " {}: avg {:.2}s = {:.1} KB, max = {:.1} KB", | |
| 464 | + | label, | |
| 465 | + | avg_dur, | |
| 466 | + | avg_bytes as f64 / 1024.0, | |
| 467 | + | max_bytes as f64 / 1024.0 | |
| 468 | + | ); | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | println!(); | |
| 472 | + | ||
| 473 | + | // STFT frame memory | |
| 474 | + | let frame_samples = 1024usize; | |
| 475 | + | let hop = 1024usize; | |
| 476 | + | let thirty_sec_frames = (30.0 * 44100.0 / hop as f64) as usize; | |
| 477 | + | let frame_mem = thirty_sec_frames * (frame_samples / 2 + 1) * 8; // f64 magnitude bins | |
| 478 | + | println!(" STFT magnitude frames (30s @ 44.1kHz, 1024-sample window):"); | |
| 479 | + | println!(" Frames: {}", thirty_sec_frames); | |
| 480 | + | println!(" Memory: {:.1} MB", frame_mem as f64 / 1_048_576.0); | |
| 481 | + | println!(); | |
| 482 | + | ||
| 483 | + | // Model size | |
| 484 | + | let model_path = project_root.join("crates/audiofiles-core/models/layer2_drum.json"); | |
| 485 | + | if let Ok(meta) = std::fs::metadata(&model_path) { | |
| 486 | + | println!(" RF model (layer2_drum.json): {:.1} MB on disk, embedded at compile time", meta.len() as f64 / 1_048_576.0); | |
| 487 | + | } | |
| 488 | + | println!(); | |
| 489 | + | ||
| 490 | + | // ───────────────────────────────────────────────────────────── | |
| 491 | + | // Section 5: Classification Accuracy | |
| 492 | + | // ───────────────────────────────────────────────────────────── | |
| 493 | + | println!("━━━ 5. CLASSIFICATION ACCURACY ━━━"); | |
| 494 | + | println!(); | |
| 495 | + | ||
| 496 | + | let class_dirs = ["kick", "snare", "hihat", "cymbal", "clap", "tom", "percussion"]; | |
| 497 | + | let max_per_class = 300; // enough for statistical significance, not too slow | |
| 498 | + | ||
| 499 | + | let mut all_results: Vec<ClassifyResult> = Vec::new(); | |
| 500 | + |
Lines truncated
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "audiofiles-browser" | |
| 3 | - | version = "0.3.2" | |
| 3 | + | version = "0.3.3" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [features] |
| @@ -64,6 +64,9 @@ define_class!( | |||
| 64 | 64 | ||
| 65 | 65 | impl DragSource { | |
| 66 | 66 | fn new(mtm: MainThreadMarker) -> Retained<Self> { | |
| 67 | + | // SAFETY: `alloc` + `init` is the standard NSObject construction pattern. | |
| 68 | + | // `MainThreadMarker` guarantees we're on the main thread, which `define_class!` | |
| 69 | + | // requires for `MainThreadOnly` types. `set_ivars(())` is infallible (no ivars). | |
| 67 | 70 | unsafe { objc2::msg_send![super(Self::alloc(mtm).set_ivars(())), init] } | |
| 68 | 71 | } | |
| 69 | 72 | } | |
| @@ -146,6 +149,8 @@ pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool { | |||
| 146 | 149 | let block = RcBlock::new(move || { | |
| 147 | 150 | do_begin_drag(&paths); | |
| 148 | 151 | }); | |
| 152 | + | // SAFETY: `_dispatch_main_q` is a valid process-global symbol provided by libdispatch. | |
| 153 | + | // `RcBlock` ensures the closure outlives the async dispatch (prevent use-after-free). | |
| 149 | 154 | unsafe { | |
| 150 | 155 | dispatch_async(&_dispatch_main_q, &block); | |
| 151 | 156 | } |
| @@ -60,6 +60,10 @@ fn hdrop_formatetc() -> FORMATETC { | |||
| 60 | 60 | /// Allocate a moveable HGLOBAL containing a DROPFILES header + UTF-16 paths. | |
| 61 | 61 | /// The buffer ends with a double-null terminator (first null ends the last | |
| 62 | 62 | /// path, second null ends the list). GMEM_ZEROINIT handles the trailing null. | |
| 63 | + | /// | |
| 64 | + | /// # Safety | |
| 65 | + | /// Caller must free the returned HGLOBAL via `GlobalFree` when done. | |
| 66 | + | /// All pointer arithmetic stays within the `total_bytes` allocation. | |
| 63 | 67 | unsafe fn build_hdrop(paths: &[PathBuf]) -> Result<(HGLOBAL, usize)> { | |
| 64 | 68 | let header_size = std::mem::size_of::<DropFilesHeader>(); | |
| 65 | 69 | let wide_paths: Vec<Vec<u16>> = paths | |
| @@ -132,11 +136,15 @@ impl IEnumFORMATETC_Impl for HDropFormatEnum_Impl { | |||
| 132 | 136 | let pos = self.pos.load(Ordering::Relaxed); | |
| 133 | 137 | if pos >= 1 || celt == 0 { | |
| 134 | 138 | if !pceltfetched.is_null() { | |
| 139 | + | // SAFETY: COM contract — pceltfetched is a valid caller-allocated | |
| 140 | + | // out-parameter when non-null (null-checked above). | |
| 135 | 141 | unsafe { *pceltfetched = 0 }; | |
| 136 | 142 | } | |
| 137 | 143 | return S_FALSE; | |
| 138 | 144 | } | |
| 139 | 145 | self.pos.store(1, Ordering::Relaxed); | |
| 146 | + | // SAFETY: COM contract — rgelt points to a caller-allocated array of at | |
| 147 | + | // least `celt` FORMATETC elements. pceltfetched is valid when non-null. | |
| 140 | 148 | unsafe { | |
| 141 | 149 | *rgelt = hdrop_formatetc(); | |
| 142 | 150 | if !pceltfetched.is_null() { | |
| @@ -177,6 +185,8 @@ struct FileDataObject { | |||
| 177 | 185 | ||
| 178 | 186 | impl Drop for FileDataObject { | |
| 179 | 187 | fn drop(&mut self) { | |
| 188 | + | // SAFETY: self.hdrop was allocated by build_hdrop via GlobalAlloc and | |
| 189 | + | // has not been freed yet (Drop runs exactly once). | |
| 180 | 190 | unsafe { | |
| 181 | 191 | let _ = GlobalFree(Some(self.hdrop)); | |
| 182 | 192 | } | |
| @@ -185,11 +195,16 @@ impl Drop for FileDataObject { | |||
| 185 | 195 | ||
| 186 | 196 | impl IDataObject_Impl for FileDataObject_Impl { | |
| 187 | 197 | fn GetData(&self, pformatetcin: *const FORMATETC) -> Result<STGMEDIUM> { | |
| 198 | + | // SAFETY: COM contract — pformatetcin is a valid non-null pointer to a | |
| 199 | + | // caller-owned FORMATETC. | |
| 188 | 200 | let fmt = unsafe { &*pformatetcin }; | |
| 189 | 201 | if fmt.cfFormat != CF_HDROP_VALUE { | |
| 190 | 202 | return Err(Error::from(DV_E_FORMATETC)); | |
| 191 | 203 | } | |
| 192 | 204 | ||
| 205 | + | // SAFETY: self.hdrop is valid (not freed until Drop). New HGLOBAL is | |
| 206 | + | // allocated at the same size. Copy stays within bounds (self.size bytes). | |
| 207 | + | // The returned STGMEDIUM transfers ownership of `h` to the caller. | |
| 193 | 208 | unsafe { | |
| 194 | 209 | let h = GlobalAlloc(GMEM_MOVEABLE, self.size)?; | |
| 195 | 210 | let src = GlobalLock(self.hdrop); | |
| @@ -211,6 +226,7 @@ impl IDataObject_Impl for FileDataObject_Impl { | |||
| 211 | 226 | } | |
| 212 | 227 | ||
| 213 | 228 | fn QueryGetData(&self, pformatetc: *const FORMATETC) -> HRESULT { | |
| 229 | + | // SAFETY: COM contract — pformatetc is a valid non-null pointer. | |
| 214 | 230 | let fmt = unsafe { &*pformatetc }; | |
| 215 | 231 | if fmt.cfFormat == CF_HDROP_VALUE { | |
| 216 | 232 | S_OK | |
| @@ -220,6 +236,8 @@ impl IDataObject_Impl for FileDataObject_Impl { | |||
| 220 | 236 | } | |
| 221 | 237 | ||
| 222 | 238 | fn GetCanonicalFormatEtc(&self, _: *const FORMATETC, pformatetcout: *mut FORMATETC) -> HRESULT { | |
| 239 | + | // SAFETY: COM contract guarantees `pformatetcout` is a valid non-null pointer | |
| 240 | + | // allocated by the caller. We only write `ptd` to indicate no device target. | |
| 223 | 241 | unsafe { | |
| 224 | 242 | (*pformatetcout).ptd = std::ptr::null_mut(); | |
| 225 | 243 | } | |
| @@ -260,6 +278,10 @@ impl IDataObject_Impl for FileDataObject_Impl { | |||
| 260 | 278 | /// completed the drop (as opposed to cancelling or dragging to a | |
| 261 | 279 | /// non-accepting target). | |
| 262 | 280 | pub(super) fn begin_drag_session(paths: &[PathBuf]) -> bool { | |
| 281 | + | // SAFETY: OleInitialize/OleUninitialize bracket the session. build_hdrop is | |
| 282 | + | // called with valid paths and its HGLOBAL is owned by FileDataObject (freed | |
| 283 | + | // on drop). COM objects are constructed via safe `into()`. DoDragDrop is the | |
| 284 | + | // standard OLE drag entry point — it blocks until the user drops or cancels. | |
| 263 | 285 | unsafe { | |
| 264 | 286 | // OleInitialize is the superset of CoInitializeEx — required for DoDragDrop. | |
| 265 | 287 | // Ok(()) means we initialized, Err with S_FALSE means already initialized (fine). |
| @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; | |||
| 9 | 9 | use std::sync::{mpsc, Mutex}; | |
| 10 | 10 | use std::thread; | |
| 11 | 11 | ||
| 12 | - | use tracing::{error, instrument}; | |
| 12 | + | use tracing::{error, instrument, warn}; | |
| 13 | 13 | ||
| 14 | 14 | use audiofiles_core::db::Database; | |
| 15 | 15 | use audiofiles_core::error::CoreError; | |
| @@ -149,7 +149,10 @@ fn count_audio_files(dir: &Path, cmd_rx: &mpsc::Receiver<ImportCommand>) -> Opti | |||
| 149 | 149 | ||
| 150 | 150 | let entries = match fs::read_dir(¤t) { | |
| 151 | 151 | Ok(e) => e, | |
| 152 | - | Err(_) => continue, | |
| 152 | + | Err(e) => { | |
| 153 | + | tracing::warn!(dir = %current.display(), "Failed to read directory during pre-walk: {e}"); | |
| 154 | + | continue; | |
| 155 | + | } | |
| 153 | 156 | }; | |
| 154 | 157 | ||
| 155 | 158 | for entry in entries.flatten() { | |
| @@ -547,6 +550,12 @@ fn worker_loop( | |||
| 547 | 550 | ||
| 548 | 551 | let total_files = if cancelled { completed } else { total }; | |
| 549 | 552 | ||
| 553 | + | // Checkpoint WAL after large import to keep -shm file fresh | |
| 554 | + | // and avoid stale memory-mapped state on macOS. | |
| 555 | + | if let Err(e) = db.wal_checkpoint() { | |
| 556 | + | warn!("WAL checkpoint after import failed: {e}"); | |
| 557 | + | } | |
| 558 | + | ||
| 550 | 559 | let _ = event_tx.send(ImportEvent::Complete { | |
| 551 | 560 | imported, | |
| 552 | 561 | total_files, |
| @@ -0,0 +1,259 @@ | |||
| 1 | + | //! Library state: smart folders, collections, similarity search, refresh helpers, mirror. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | impl BrowserState { | |
| 6 | + | // --- Misc helpers --- | |
| 7 | + | ||
| 8 | + | /// Absolute filesystem path to the focused sample, or `None` if no sample is selected. | |
| 9 | + | pub fn selected_sample_path(&self) -> Option<String> { | |
| 10 | + | let node = self.selected_node()?; | |
| 11 | + | let hash = node.node.sample_hash.as_ref()?; | |
| 12 | + | let path = self.resolve_sample_path(hash).ok()?; | |
| 13 | + | Some(path.to_string_lossy().into_owned()) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Reload the VFS list from the database and reset navigation to root. | |
| 17 | + | pub fn refresh_vfs_list(&mut self) { | |
| 18 | + | self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| { | |
| 19 | + | error!("Failed to refresh VFS list: {e}"); | |
| 20 | + | Vec::new() | |
| 21 | + | })); | |
| 22 | + | if self.current_vfs_idx >= self.vfs_list.len() { | |
| 23 | + | self.current_vfs_idx = 0; | |
| 24 | + | } | |
| 25 | + | self.current_dir = None; | |
| 26 | + | self.breadcrumb.clear(); | |
| 27 | + | self.selection.clear(); | |
| 28 | + | self.refresh_contents(); | |
| 29 | + | self.refresh_smart_folders(); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Refresh the cached list of all tags. | |
| 33 | + | pub fn refresh_all_tags(&mut self) { | |
| 34 | + | self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| { | |
| 35 | + | warn!("Failed to refresh tags: {e}"); | |
| 36 | + | Vec::new() | |
| 37 | + | })); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // --- Smart folders --- | |
| 41 | + | ||
| 42 | + | /// Refresh the smart folder list for the current VFS. | |
| 43 | + | pub fn refresh_smart_folders(&mut self) { | |
| 44 | + | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 45 | + | self.smart_folders = self.backend.list_smart_folders(vfs_id) | |
| 46 | + | .unwrap_or_else(|e| { | |
| 47 | + | warn!("Failed to load smart folders: {e}"); | |
| 48 | + | Vec::new() | |
| 49 | + | }); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Save the current search filter as a smart folder with the given name. | |
| 53 | + | pub fn save_smart_folder(&mut self, name: &str) { | |
| 54 | + | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 55 | + | match self.backend.create_smart_folder(vfs_id, name, &self.search_filter) { | |
| 56 | + | Ok(_) => { | |
| 57 | + | self.status = format!("Saved smart folder: {name}"); | |
| 58 | + | } | |
| 59 | + | Err(e) => { | |
| 60 | + | self.status = format!("Failed to save smart folder: {e}"); | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | self.refresh_smart_folders(); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Activate a smart folder by index: apply its filter and refresh. | |
| 67 | + | pub fn activate_smart_folder(&mut self, idx: usize) { | |
| 68 | + | if let Some(folder) = self.smart_folders.get(idx) { | |
| 69 | + | self.search_filter = folder.filter.clone(); | |
| 70 | + | self.search_query = folder.filter.text_query.clone(); | |
| 71 | + | self.similarity_search_hash = None; | |
| 72 | + | self.selection.clear(); | |
| 73 | + | self.refresh_contents(); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Delete a smart folder by index. | |
| 78 | + | pub fn delete_smart_folder(&mut self, idx: usize) { | |
| 79 | + | if let Some(folder) = self.smart_folders.get(idx) { | |
| 80 | + | if let Err(e) = self.backend.delete_smart_folder(folder.id) { | |
| 81 | + | self.status = format!("Failed to delete smart folder: {e}"); | |
| 82 | + | return; | |
| 83 | + | } | |
| 84 | + | self.refresh_smart_folders(); | |
| 85 | + | self.status = "Smart folder deleted".to_string(); | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // --- Collections --- | |
| 90 | + | ||
| 91 | + | /// Refresh the collection list from the database. | |
| 92 | + | pub fn refresh_collections(&mut self) { | |
| 93 | + | self.collections = self.backend.list_collections() | |
| 94 | + | .unwrap_or_else(|e| { | |
| 95 | + | warn!("Failed to load collections: {e}"); | |
| 96 | + | Vec::new() | |
| 97 | + | }); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Activate a collection: show its members in the file list. | |
| 101 | + | pub fn activate_collection(&mut self, id: CollectionId) { | |
| 102 | + | let hashes = match self.backend.list_collection_members(id) { | |
| 103 | + | Ok(h) => h, | |
| 104 | + | Err(e) => { | |
| 105 | + | self.status = format!("Failed to load collection: {e}"); | |
| 106 | + | return; | |
| 107 | + | } | |
| 108 | + | }; | |
| 109 | + | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 110 | + | let hash_refs: Vec<&str> = hashes.iter().map(|s| s.as_str()).collect(); | |
| 111 | + | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hash_refs) | |
| 112 | + | .unwrap_or_default(); | |
| 113 | + | let count = nodes.len(); | |
| 114 | + | self.contents = Arc::new(nodes); | |
| 115 | + | self.active_collection = Some(id); | |
| 116 | + | self.similarity_search_hash = None; | |
| 117 | + | self.selection.clear(); | |
| 118 | + | self.status = format!("{count} samples in collection"); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Deactivate collection view and return to normal browsing. | |
| 122 | + | pub fn deactivate_collection(&mut self) { | |
| 123 | + | self.active_collection = None; | |
| 124 | + | self.refresh_contents(); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // --- Similarity search --- | |
| 128 | + | ||
| 129 | + | /// Find samples similar to the given hash and display them. | |
| 130 | + | pub fn find_similar(&mut self, hash: &str) { | |
| 131 | + | match self.backend.find_similar(hash, 50) { | |
| 132 | + | Ok(results) => { | |
| 133 | + | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 134 | + | let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); | |
| 135 | + | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) | |
| 136 | + | .unwrap_or_default(); | |
| 137 | + | let count = nodes.len(); | |
| 138 | + | self.contents = Arc::new(nodes); | |
| 139 | + | self.similarity_search_hash = Some(hash.to_string()); | |
| 140 | + | self.selection.clear(); | |
| 141 | + | self.status = format!("Found {count} similar samples"); | |
| 142 | + | } | |
| 143 | + | Err(e) => { | |
| 144 | + | self.status = format!("Similarity search failed: {e}"); | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Find near-duplicates of the given sample by comparing peak envelope fingerprints. | |
| 150 | + | pub fn find_near_duplicates(&mut self, hash: &str) { | |
| 151 | + | match self.backend.find_near_duplicates(hash, 50) { | |
| 152 | + | Ok(results) => { | |
| 153 | + | let vfs_id = self.vfs_list[self.current_vfs_idx].id; | |
| 154 | + | let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect(); | |
| 155 | + | let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes) | |
| 156 | + | .unwrap_or_default(); | |
| 157 | + | let count = nodes.len(); | |
| 158 | + | self.contents = Arc::new(nodes); | |
| 159 | + | self.similarity_search_hash = Some(hash.to_string()); | |
| 160 | + | self.selection.clear(); | |
| 161 | + | self.status = format!("Found {count} near-duplicates"); | |
| 162 | + | } | |
| 163 | + | Err(e) => { | |
| 164 | + | self.status = format!("Duplicate search failed: {e}"); | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// Clear similarity search mode and return to normal browsing. | |
| 170 | + | pub fn clear_similarity_search(&mut self) { | |
| 171 | + | self.similarity_search_hash = None; | |
| 172 | + | self.refresh_contents(); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // --- Column config --- | |
| 176 | + | ||
| 177 | + | /// Load column config from the user_config table. | |
| 178 | + | pub fn load_column_config(&mut self) { | |
| 179 | + | if let Ok(Some(json)) = self.backend.get_config("column_config") { | |
| 180 | + | if let Ok(parsed) = serde_json::from_str::<ColumnConfig>(&json) { | |
| 181 | + | self.column_config = parsed; | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Save the current theme ID to the user_config table. | |
| 187 | + | pub fn save_theme_preference(&self) { | |
| 188 | + | let _ = self.backend.set_config("theme", &self.current_theme_id); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Save column config to the user_config table. | |
| 192 | + | pub fn save_column_config(&self) { | |
| 193 | + | // unwrap is safe: ColumnConfig contains only primitive fields (bools, enums) | |
| 194 | + | // with derived Serialize impls, so serialisation cannot fail. | |
| 195 | + | let json = serde_json::to_string(&self.column_config).unwrap(); | |
| 196 | + | let _ = self.backend.set_config("column_config", &json); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // --- VFS Mirror --- | |
| 200 | + | ||
| 201 | + | /// Mark the VFS mirror as needing a re-sync. | |
| 202 | + | pub fn mark_mirror_dirty(&mut self) { | |
| 203 | + | if self.mirror_enabled { | |
| 204 | + | self.mirror_dirty = true; | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Run a mirror sync if the dirty flag is set. Returns true if a sync ran. | |
| 209 | + | pub fn sync_mirror_if_dirty(&mut self) -> bool { | |
| 210 | + | if !self.mirror_enabled || !self.mirror_dirty { | |
| 211 | + | return false; | |
| 212 | + | } | |
| 213 | + | self.mirror_dirty = false; | |
| 214 | + | match self.backend.sync_vfs_mirror(&self.mirror_path) { | |
| 215 | + | Ok((dirs, links, removed)) => { | |
| 216 | + | if dirs + links + removed > 0 { | |
| 217 | + | tracing::debug!(dirs, links, removed, "Mirror synced"); | |
| 218 | + | } | |
| 219 | + | true | |
| 220 | + | } | |
| 221 | + | Err(e) => { | |
| 222 | + | tracing::warn!("Mirror sync failed: {e}"); | |
| 223 | + | false | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Enable or disable the VFS mirror. Persists the setting. | |
| 229 | + | pub fn set_mirror_enabled(&mut self, enabled: bool) { | |
| 230 | + | self.mirror_enabled = enabled; | |
| 231 | + | let _ = self | |
| 232 | + | .backend | |
| 233 | + | .set_config("mirror_enabled", if enabled { "1" } else { "0" }); | |
| 234 | + | ||
| 235 | + | if enabled { | |
| 236 | + | // Run initial sync immediately. | |
| 237 | + | self.mirror_dirty = true; | |
| 238 | + | self.sync_mirror_if_dirty(); | |
| 239 | + | } else { | |
| 240 | + | // Remove the mirror directory. | |
| 241 | + | let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// Set the mirror path. Persists the setting. | |
| 246 | + | pub fn set_mirror_path(&mut self, path: PathBuf) { | |
| 247 | + | // If mirror is enabled, remove old mirror before switching. | |
| 248 | + | if self.mirror_enabled { | |
| 249 | + | let _ = audiofiles_core::vfs_mirror::remove_mirror(&self.mirror_path); | |
| 250 | + | } | |
| 251 | + | self.mirror_path = path; | |
| 252 | + | let _ = self | |
| 253 | + | .backend | |
| 254 | + | .set_config("mirror_path", &self.mirror_path.to_string_lossy()); | |
| 255 | + | if self.mirror_enabled { | |
| 256 | + | self.mirror_dirty = true; | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } |
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | //! [`SharedState`] bridges the cpal audio output thread and the GUI thread via lock-free access. | |
| 4 | 4 | //! [`BrowserState`] holds the full GUI-side model: VFS navigation, preview, and analysis workflow. | |
| 5 | 5 | ||
| 6 | - | use std::collections::HashSet; | |
| 7 | 6 | use std::fs; | |
| 8 | 7 | use std::path::{Path, PathBuf}; | |
| 9 | 8 | use std::sync::Arc; | |
| @@ -13,17 +12,15 @@ use std::time::Instant; | |||
| 13 | 12 | use tracing::{error, warn}; | |
| 14 | 13 | ||
| 15 | 14 | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 16 | - | use audiofiles_core::util::split_name_ext; | |
| 17 | - | use audiofiles_core::analysis::suggest::TagSuggestion; | |
| 18 | 15 | use audiofiles_core::analysis::waveform::WaveformData; | |
| 19 | 16 | use audiofiles_core::analysis::AnalysisResult; | |
| 20 | 17 | use audiofiles_core::db::Database; | |
| 21 | - | use audiofiles_core::edit::{EditOperation, FadeCurve}; | |
| 22 | 18 | use audiofiles_core::error::CoreError; | |
| 23 | 19 | use audiofiles_core::collections::Collection; | |
| 24 | 20 | use audiofiles_core::search::SearchFilter; | |
| 25 | 21 | use audiofiles_core::smart_folders::SmartFolder; | |
| 26 | 22 | use audiofiles_core::store::SampleStore; | |
| 23 | + | use audiofiles_core::util::split_name_ext; | |
| 27 | 24 | use audiofiles_core::vfs::{NodeType, Vfs, VfsNode}; | |
| 28 | 25 | use audiofiles_core::{CollectionId, NodeId, VfsId}; | |
| 29 | 26 | pub use audiofiles_core::vfs::VfsNodeWithAnalysis; | |
| @@ -38,10 +35,16 @@ use crate::preview::PreviewPlayback; | |||
| 38 | 35 | mod navigation; | |
| 39 | 36 | mod import_workflow; | |
| 40 | 37 | mod bulk_ops; | |
| 38 | + | mod library; | |
| 39 | + | mod playback; | |
| 40 | + | mod ui; | |
| 41 | 41 | ||
| 42 | 42 | #[cfg(test)] | |
| 43 | 43 | mod tests; | |
| 44 | 44 | ||
| 45 | + | // Re-export all UI types so they remain accessible at `crate::state::*` | |
| 46 | + | pub use ui::*; | |
| 47 | + | ||
| 45 | 48 | /// Shared between cpal audio output thread and GUI thread. | |
| 46 | 49 | /// Audio thread uses try_lock -- never blocks. | |
| 47 | 50 | pub struct SharedState { | |
| @@ -73,484 +76,6 @@ impl SharedState { | |||
| 73 | 76 | } | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | - | ||
| 77 | - | /// User preference for how to handle edit results. | |
| 78 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 79 | - | pub enum EditResultMode { | |
| 80 | - | /// Replace the VFS node in-place with the edited sample. | |
| 81 | - | Replace, | |
| 82 | - | /// Create a sibling node next to the original. | |
| 83 | - | Sibling, | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | /// Data from a completed edit, pending user choice (replace vs sibling). | |
| 87 | - | pub struct PendingEditResult { | |
| 88 | - | pub source_hash: String, | |
| 89 | - | pub result_path: PathBuf, | |
| 90 | - | pub operation: EditOperation, | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | /// Pending destructive action awaiting user confirmation. | |
| 94 | - | pub enum ConfirmAction { | |
| 95 | - | DeleteNode { node_id: NodeId, node_name: String }, | |
| 96 | - | DeleteVfs { vfs_id: VfsId, vfs_name: String }, | |
| 97 | - | DeleteMultiple { node_ids: Vec<NodeId>, count: usize }, | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | /// An undoable bulk operation. | |
| 101 | - | pub enum UndoOp { | |
| 102 | - | BulkDelete { | |
| 103 | - | nodes: Vec<VfsNode>, | |
| 104 | - | tags: Vec<(String, Vec<String>)>, | |
| 105 | - | }, | |
| 106 | - | BulkMove { | |
| 107 | - | moves: Vec<(NodeId, Option<NodeId>)>, | |
| 108 | - | }, | |
| 109 | - | BulkRename { | |
| 110 | - | renames: Vec<(NodeId, String)>, | |
| 111 | - | }, | |
| 112 | - | BulkTagAdd { | |
| 113 | - | tag: String, | |
| 114 | - | hashes: Vec<String>, | |
| 115 | - | }, | |
| 116 | - | BulkTagRemove { | |
| 117 | - | tag: String, | |
| 118 | - | hashes: Vec<String>, | |
| 119 | - | }, | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | /// Active bulk operation modal. | |
| 123 | - | pub enum BulkModal { | |
| 124 | - | /// Bulk add or remove a tag from selected samples. | |
| 125 | - | Tag { | |
| 126 | - | /// The tag string being entered by the user. | |
| 127 | - | tag_input: String, | |
| 128 | - | /// `true` for add, `false` for remove. | |
| 129 | - | adding: bool, | |
| 130 | - | /// Content-addressed hashes of the targeted samples. | |
| 131 | - | hashes: Vec<String>, | |
| 132 | - | /// Display names of the targeted samples. | |
| 133 | - | names: Vec<String>, | |
| 134 | - | }, | |
| 135 | - | /// Bulk move selected nodes to a different directory. | |
| 136 | - | Move { | |
| 137 | - | /// IDs of the VFS nodes being moved. | |
| 138 | - | node_ids: Vec<NodeId>, | |
| 139 | - | /// Display names of the nodes being moved. | |
| 140 | - | names: Vec<String>, | |
| 141 | - | /// All available target directories as `(id, full_path)` pairs. | |
| 142 | - | directories: Vec<(NodeId, String)>, | |
| 143 | - | /// Index into `directories` the user has chosen, or `None` for root. | |
| 144 | - | selected_idx: Option<usize>, | |
| 145 | - | }, | |
| 146 | - | /// Bulk rename selected nodes using a pattern template. | |
| 147 | - | Rename { | |
| 148 | - | /// The rename pattern string (e.g. `"{name}_{bpm}"`). | |
| 149 | - | pattern_input: String, | |
| 150 | - | /// The nodes targeted for rename, with their analysis context. | |
| 151 | - | targets: Vec<RenameTarget>, | |
| 152 | - | /// Live preview pairs of `(old_name, new_name)`. | |
| 153 | - | previews: Vec<(String, String)>, | |
| 154 | - | /// Validation error, if any (empty names, duplicates, bad pattern). | |
| 155 | - | error: Option<String>, | |
| 156 | - | }, | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | /// A rename target with its context for preview. | |
| 160 | - | pub struct RenameTarget { | |
| 161 | - | pub node_id: NodeId, | |
| 162 | - | pub context: audiofiles_core::rename::RenameContext, | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | /// Column visibility configuration. | |
| 166 | - | #[derive(serde::Serialize, serde::Deserialize)] | |
| 167 | - | pub struct ColumnConfig { | |
| 168 | - | pub show_classification: bool, | |
| 169 | - | pub show_bpm: bool, | |
| 170 | - | pub show_key: bool, | |
| 171 | - | pub show_duration: bool, | |
| 172 | - | pub show_peak_db: bool, | |
| 173 | - | pub show_tags: bool, | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | impl Default for ColumnConfig { | |
| 177 | - | fn default() -> Self { | |
| 178 | - | Self { | |
| 179 | - | show_classification: true, | |
| 180 | - | show_bpm: true, | |
| 181 | - | show_key: true, | |
| 182 | - | show_duration: true, | |
| 183 | - | show_peak_db: false, | |
| 184 | - | show_tags: false, | |
| 185 | - | } | |
| 186 | - | } | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | /// A file that failed during the import phase (before entering the store). | |
| 190 | - | pub struct ImportFileError { | |
| 191 | - | pub path: String, | |
| 192 | - | pub error: String, | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | /// A file that entered the store but failed during analysis. | |
| 196 | - | pub struct AnalysisFileError { | |
| 197 | - | pub hash: String, | |
| 198 | - | pub name: String, | |
| 199 | - | pub error: String, | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | /// Status of the sync API key test flow. | |
| 203 | - | pub enum SyncSetupStatus { | |
| 204 | - | /// No test in progress. | |
| 205 | - | Idle, | |
| 206 | - | /// Validation request in flight. | |
| 207 | - | Testing, | |
| 208 | - | /// Key is valid; server returned the app name. | |
| 209 | - | Valid { app_name: String }, | |
| 210 | - | /// Key is invalid or server unreachable. | |
| 211 | - | Failed { error: String }, | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | /// Actions the sync setup UI can request from the app layer. | |
| 215 | - | pub enum SyncSetupAction { | |
| 216 | - | /// Validate this API key against the server. | |
| 217 | - | TestKey(String), | |
| 218 | - | /// Save this API key and create a SyncManager. | |
| 219 | - | SaveKey(String), | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | /// Actions the vault picker UI can request from the app layer. | |
| 223 | - | pub enum VaultAction { | |
| 224 | - | /// Switch to a different vault. | |
| 225 | - | SwitchVault(PathBuf), | |
| 226 | - | /// Create a new vault and switch to it. | |
| 227 | - | CreateVault { name: String, path: PathBuf }, | |
| 228 | - | /// Add an existing vault directory to the registry. | |
| 229 | - | AddExistingVault { name: String, path: PathBuf }, | |
| 230 | - | /// Remove a vault from the registry (no file deletion). | |
| 231 | - | RemoveVault(PathBuf), | |
| 232 | - | /// Rename a vault in the registry. | |
| 233 | - | RenameVault { path: PathBuf, new_name: String }, | |
| 234 | - | /// Scan storage stats for the active vault. | |
| 235 | - | ScanStorage, | |
| 236 | - | /// Deactivate the license key and return to activation screen. | |
| 237 | - | DeactivateLicense, | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | /// GUI state for the consolidated Settings window. | |
| 241 | - | #[derive(Default)] | |
| 242 | - | pub struct SettingsUiState { | |
| 243 | - | /// Display name of the active vault. | |
| 244 | - | pub name: String, | |
| 245 | - | /// (name, path, reachable) for each known vault. | |
| 246 | - | pub list: Vec<(String, PathBuf, bool)>, | |
| 247 | - | /// Set by the UI, consumed by the app layer each frame. | |
| 248 | - | pub pending_action: Option<VaultAction>, | |
| 249 | - | /// Whether the Settings window is open. | |
| 250 | - | pub show_manager: bool, | |
| 251 | - | /// Name input for creating/adding a vault. | |
| 252 | - | pub create_name: String, | |
| 253 | - | /// Path input for creating/adding a vault. | |
| 254 | - | pub create_path: Option<PathBuf>, | |
| 255 | - | /// Inline rename: (path, new_name_buffer). | |
| 256 | - | pub rename_target: Option<(PathBuf, String)>, | |
| 257 | - | ||
| 258 | - | /// Cached storage statistics from the last scan. | |
| 259 | - | pub storage_cache: Option<crate::backend::StorageStats>, | |
| 260 | - | /// Whether a storage scan is in progress. | |
| 261 | - | pub storage_scanning: bool, | |
| 262 | - | /// Masked license key for display. | |
| 263 | - | pub license_key_masked: Option<String>, | |
| 264 | - | /// Machine ID for display. | |
| 265 | - | pub machine_id: Option<String>, | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | /// GUI state for the sync setup and panel. | |
| 269 | - | pub struct SyncUiState { | |
| 270 | - | /// Whether the sync panel overlay is open. | |
| 271 | - | pub show_panel: bool, | |
| 272 | - | pub encryption_input: String, | |
| 273 | - | pub auth_code_input: String, | |
| 274 | - | /// API key input for initial setup. | |
| 275 | - | pub api_key_input: String, | |
| 276 | - | /// Status of the API key test flow. | |
| 277 | - | pub setup_status: SyncSetupStatus, | |
| 278 | - | /// Set by the UI, consumed by the app layer each frame. | |
| 279 | - | pub pending_action: Option<SyncSetupAction>, | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | impl Default for SyncUiState { | |
| 283 | - | fn default() -> Self { | |
| 284 | - | Self { | |
| 285 | - | show_panel: false, | |
| 286 | - | encryption_input: String::new(), | |
| 287 | - | auth_code_input: String::new(), | |
| 288 | - | api_key_input: String::new(), | |
| 289 | - | setup_status: SyncSetupStatus::Idle, | |
| 290 | - | pending_action: None, | |
| 291 | - | } | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | /// GUI state for the floating sample editor window. | |
| 296 | - | pub struct EditUiState { | |
| 297 | - | pub show_window: bool, | |
| 298 | - | pub hash: Option<String>, | |
| 299 | - | pub in_progress: bool, | |
| 300 | - | pub result_prompt: bool, | |
| 301 | - | pub pending_result: Option<PendingEditResult>, | |
| 302 | - | pub trim_start: f32, | |
| 303 | - | pub trim_end: f32, | |
| 304 | - | pub total_frames: usize, | |
| 305 | - | pub gain_db: f64, | |
| 306 | - | pub norm_peak: bool, | |
| 307 | - | pub norm_target: f64, | |
| 308 | - | pub fade_in: bool, | |
| 309 | - | pub fade_duration_ms: f64, | |
| 310 | - | pub fade_curve: FadeCurve, | |
| 311 | - | pub result_mode: Option<EditResultMode>, | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | impl Default for EditUiState { | |
| 315 | - | fn default() -> Self { | |
| 316 | - | Self { | |
| 317 | - | show_window: false, | |
| 318 | - | hash: None, | |
| 319 | - | in_progress: false, | |
| 320 | - | result_prompt: false, | |
| 321 | - | pending_result: None, | |
| 322 | - | trim_start: 0.0, | |
| 323 | - | trim_end: 1.0, | |
| 324 | - | total_frames: 0, | |
| 325 | - | gain_db: 0.0, | |
| 326 | - | norm_peak: true, | |
| 327 | - | norm_target: -0.1, | |
| 328 | - | fade_in: true, | |
| 329 | - | fade_duration_ms: 100.0, | |
| 330 | - | fade_curve: FadeCurve::Linear, | |
| 331 | - | result_mode: None, | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | /// Actions the MIDI setup UI can request from the app layer. | |
| 337 | - | pub enum MidiAction { | |
| 338 | - | /// Connect to the MIDI input port at this index. | |
| 339 | - | Connect(usize), | |
| 340 | - | /// Disconnect the current MIDI input. | |
| 341 | - | Disconnect, | |
| 342 | - | /// Re-enumerate available ports. | |
| 343 | - | RefreshPorts, | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | /// A recent MIDI note event for the activity display. | |
| 347 | - | pub struct MidiNoteEvent { | |
| 348 | - | pub note: u8, | |
| 349 | - | pub velocity: u8, | |
| 350 | - | pub note_name: String, | |
| 351 | - | pub timestamp: Instant, | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | /// GUI-side state for the MIDI device picker and activity display. | |
| 355 | - | #[derive(Default)] | |
| 356 | - | pub struct MidiUiState { | |
| 357 | - | pub available_ports: Vec<String>, | |
| 358 | - | pub connected_port: Option<usize>, | |
| 359 | - | pub connected_port_name: Option<String>, | |
| 360 | - | pub recent_notes: Vec<MidiNoteEvent>, | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | /// A top-level imported folder with a user-editable tag input. | |
| 364 | - | pub struct FolderTagEntry { | |
| 365 | - | pub folder: ImportedFolder, | |
| 366 | - | pub tag_input: String, | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | /// Current import/analysis workflow state. | |
| 370 | - | pub enum ImportMode { | |
| 371 | - | None, | |
| 372 | - | ConfigureImport { | |
| 373 | - | source: PathBuf, | |
| 374 | - | source_name: String, | |
| 375 | - | strategy: ImportStrategy, | |
| 376 | - | available_vfs: Vec<Vfs>, | |
| 377 | - | selected_merge_vfs_idx: usize, | |
| 378 | - | new_vfs_name: String, | |
| 379 | - | }, | |
| 380 | - | Importing { | |
| 381 | - | total: usize, | |
| 382 | - | completed: usize, | |
| 383 | - | current_name: String, | |
| 384 | - | walking: bool, | |
| 385 | - | }, | |
| 386 | - | TagFolders { | |
| 387 | - | entries: Vec<FolderTagEntry>, | |
| 388 | - | sample_hashes: Vec<(String, String)>, | |
| 389 | - | }, | |
| 390 | - | ConfigureAnalysis { | |
| 391 | - | sample_hashes: Vec<(String, String)>, | |
| 392 | - | config: AnalysisConfig, | |
| 393 | - | }, | |
| 394 | - | Analyzing { | |
| 395 | - | completed: usize, | |
| 396 | - | total: usize, | |
| 397 | - | current_name: String, | |
| 398 | - | }, | |
| 399 | - | ReviewSuggestions { | |
| 400 | - | items: Vec<ReviewItem>, | |
| 401 | - | current_idx: usize, | |
| 402 | - | }, | |
| 403 | - | ConfigureExport { | |
| 404 | - | items: Vec<audiofiles_core::export::ExportItem>, | |
| 405 | - | config: audiofiles_core::export::ExportConfig, | |
| 406 | - | /// Available device profiles for the profile picker. | |
| 407 | - | available_profiles: Vec<audiofiles_core::export::profile::DeviceProfileSummary>, | |
| 408 | - | }, | |
| 409 | - | Exporting { | |
| 410 | - | completed: usize, | |
| 411 | - | total: usize, | |
| 412 | - | current_name: String, | |
| 413 | - | }, | |
| 414 | - | ExportComplete { | |
| 415 | - | total: usize, | |
| 416 | - | errors: Vec<(String, String)>, | |
| 417 | - | }, | |
| 418 | - | ReviewErrors, | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | /// A sample with its analysis results and pending tag suggestions. | |
| 422 | - | pub struct ReviewItem { | |
| 423 | - | pub hash: audiofiles_core::SampleHash, | |
| 424 | - | pub name: String, | |
| 425 | - | pub result: AnalysisResult, | |
| 426 | - | pub suggestions: Vec<SuggestionState>, | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | /// A tag suggestion the user can accept or reject. | |
| 430 | - | pub struct SuggestionState { | |
| 431 | - | pub suggestion: TagSuggestion, | |
| 432 | - | pub accepted: bool, | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | // --- Sort --- | |
| 436 | - | ||
| 437 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 438 | - | pub enum SortColumn { | |
| 439 | - | Name, | |
| 440 | - | Bpm, | |
| 441 | - | Key, | |
| 442 | - | Duration, | |
| 443 | - | Classification, | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 447 | - | pub enum SortDirection { | |
| 448 | - | Ascending, | |
| 449 | - | Descending, | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | // --- Selection --- | |
| 453 | - | ||
| 454 | - | /// Multi-selection state with anchor-based range selection. | |
| 455 | - | /// | |
| 456 | - | /// Three indices work together: `anchor` is where a shift-click range starts, | |
| 457 | - | /// `focus` tracks the most recently interacted-with row (the cursor), | |
| 458 | - | /// and `selected` is the full set of selected row indices. A plain click | |
| 459 | - | /// sets all three to the same row; shift-click extends from anchor to the | |
| 460 | - | /// clicked row; cmd-click toggles one row without moving the anchor. | |
| 461 | - | #[derive(Debug, Clone, Default)] | |
| 462 | - | pub struct Selection { | |
| 463 | - | /// The anchor point for shift-click range selection. | |
| 464 | - | pub anchor: usize, | |
| 465 | - | /// The focused (most recently selected) item. | |
| 466 | - | pub focus: usize, | |
| 467 | - | /// Set of selected indices. | |
| 468 | - | pub selected: HashSet<usize>, | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | impl Selection { | |
| 472 | - | /// Create an empty selection with anchor and focus at index 0. | |
| 473 | - | pub fn new() -> Self { | |
| 474 | - | Self::default() | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | /// Clear the selection and reset anchor/focus to 0. | |
| 478 | - | pub fn clear(&mut self) { | |
| 479 | - | self.selected.clear(); | |
| 480 | - | self.anchor = 0; | |
| 481 | - | self.focus = 0; | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | /// Single-select one item, clearing all others. | |
| 485 | - | pub fn set_single(&mut self, idx: usize) { | |
| 486 | - | self.selected.clear(); | |
| 487 | - | self.selected.insert(idx); | |
| 488 | - | self.anchor = idx; | |
| 489 | - | self.focus = idx; | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | /// Toggle an item in the selection (Cmd+Click). | |
| 493 | - | pub fn toggle(&mut self, idx: usize) { | |
| 494 | - | if self.selected.contains(&idx) { | |
| 495 | - | self.selected.remove(&idx); | |
| 496 | - | } else { | |
| 497 | - | self.selected.insert(idx); | |
| 498 | - | } | |
| 499 | - | self.anchor = idx; | |
| 500 | - | self.focus = idx; | |
| 501 | - | } | |
| 502 | - | ||
| 503 | - | /// Extend selection from anchor to target (Shift+Click). | |
| 504 | - | /// `_max_len` is accepted for API consistency but unused — the range is | |
| 505 | - | /// always anchor..=target regardless of list length. | |
| 506 | - | pub fn extend_to(&mut self, target: usize, _max_len: usize) { | |
| 507 | - | let start = self.anchor.min(target); | |
| 508 | - | let end = self.anchor.max(target); | |
| 509 | - | for i in start..=end { | |
| 510 | - | self.selected.insert(i); | |
| 511 | - | } | |
| 512 | - | self.focus = target; | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | /// Extend selection down by one (Shift+Down). | |
| 516 | - | pub fn extend_down(&mut self, max_len: usize) { | |
| 517 | - | if max_len > 0 && self.focus < max_len - 1 { | |
| 518 | - | self.focus += 1; | |
| 519 | - | self.selected.insert(self.focus); | |
| 520 | - | } | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | /// Extend selection up by one (Shift+Up). | |
| 524 | - | pub fn extend_up(&mut self) { | |
| 525 | - | if self.focus > 0 { | |
| 526 | - | self.focus -= 1; | |
| 527 | - | self.selected.insert(self.focus); | |
| 528 | - | } | |
| 529 | - | } | |
| 530 | - | ||
| 531 | - | /// Select all items. |
Lines truncated
| @@ -0,0 +1,241 @@ | |||
| 1 | + | //! Playback state: preview, instrument, sample resolution. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | impl BrowserState { | |
| 6 | + | // --- Sample resolution --- | |
| 7 | + | ||
| 8 | + | /// Resolve a sample hash to its filesystem path via the backend. | |
| 9 | + | pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, String> { | |
| 10 | + | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 11 | + | let path = self.backend.sample_path(hash, &ext) | |
| 12 | + | .map_err(|e| format!("Invalid hash: {e}"))?; | |
| 13 | + | if !path.exists() { | |
| 14 | + | return Err(format!("File not found: {}", path.display())); | |
| 15 | + | } | |
| 16 | + | Ok(path) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer. | |
| 20 | + | pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, String> { | |
| 21 | + | let path = self.resolve_sample_path(hash)?; | |
| 22 | + | crate::preview::decode_to_f32(&path).map_err(|e| format!("Decode error: {e}")) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // --- Preview --- | |
| 26 | + | ||
| 27 | + | /// Decode a sample by hash and start playback through the shared preview buffer. | |
| 28 | + | /// | |
| 29 | + | /// Short files (<=30s or unknown duration) are decoded fully on the GUI thread. | |
| 30 | + | /// Long files use streaming: a background thread decodes while playback starts | |
| 31 | + | /// after a 0.5s pre-fill, avoiding UI freezes. | |
| 32 | + | pub fn trigger_preview(&mut self, hash: &str) { | |
| 33 | + | let path = match self.resolve_sample_path(hash) { | |
| 34 | + | Ok(p) => p, | |
| 35 | + | Err(e) => { | |
| 36 | + | self.status = e; | |
| 37 | + | self.previewing_hash = None; | |
| 38 | + | return; | |
| 39 | + | } | |
| 40 | + | }; | |
| 41 | + | ||
| 42 | + | let duration = crate::preview::estimate_duration(&path); | |
| 43 | + | let use_streaming = duration.is_some_and(|d| d > crate::preview::STREAMING_THRESHOLD_SECS); | |
| 44 | + | ||
| 45 | + | let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); | |
| 46 | + | let ok = if use_streaming { | |
| 47 | + | match crate::preview::start_streaming_decode(&path, &self.shared) { | |
| 48 | + | Ok(()) => { | |
| 49 | + | self.previewing_hash = Some(hash.to_string()); | |
| 50 | + | self.status = format!("Playing: {file_name}"); | |
| 51 | + | true | |
| 52 | + | } | |
| 53 | + | Err(e) => { | |
| 54 | + | self.status = format!("Decode error: {e}"); | |
| 55 | + | self.previewing_hash = None; | |
| 56 | + | false | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } else { | |
| 60 | + | match crate::preview::decode_to_f32(&path) { | |
| 61 | + | Ok(buf) => { | |
| 62 | + | let mut playback = self.shared.preview.lock(); | |
| 63 | + | playback.buffer = Some(buf); | |
| 64 | + | playback.position_frac = 0.0; | |
| 65 | + | playback.playing = true; | |
| 66 | + | playback.loop_enabled = self.loop_enabled; | |
| 67 | + | playback.streaming = false; | |
| 68 | + | playback.decoded_frames = 0; | |
| 69 | + | playback.total_frames_estimate = None; | |
| 70 | + | self.previewing_hash = Some(hash.to_string()); | |
| 71 | + | self.status = format!("Playing: {file_name}"); | |
| 72 | + | true | |
| 73 | + | } | |
| 74 | + | Err(e) => { | |
| 75 | + | self.status = format!("Decode error: {e}"); | |
| 76 | + | self.previewing_hash = None; | |
| 77 | + | false | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | // Auto-load previewed sample into the instrument (unless locked) | |
| 83 | + | if ok && !self.instrument_locked { | |
| 84 | + | let hash_owned = hash.to_string(); | |
| 85 | + | self.load_chromatic_sample(&hash_owned); | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Stop playback and clear the previewing state. | |
| 90 | + | pub fn stop_preview(&mut self) { | |
| 91 | + | let mut playback = self.shared.preview.lock(); | |
| 92 | + | playback.playing = false; | |
| 93 | + | playback.position_frac = 0.0; | |
| 94 | + | self.previewing_hash = None; | |
| 95 | + | self.status.clear(); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Toggle preview: stop if playing, otherwise preview the focused sample. | |
| 99 | + | pub fn toggle_preview(&mut self) { | |
| 100 | + | if self.shared.preview.lock().playing { | |
| 101 | + | self.stop_preview(); | |
| 102 | + | } else if let Some(node) = self.selected_node() { | |
| 103 | + | if let Some(hash) = &node.node.sample_hash { | |
| 104 | + | let hash = hash.clone(); | |
| 105 | + | self.trigger_preview(&hash); | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// If autoplay is enabled and the focused node is a sample, preview it. | |
| 111 | + | pub fn autoplay_current(&mut self) { | |
| 112 | + | if !self.autoplay { | |
| 113 | + | return; | |
| 114 | + | } | |
| 115 | + | if let Some(node) = self.selected_node() { | |
| 116 | + | if let Some(hash) = &node.node.sample_hash { | |
| 117 | + | let hash = hash.clone(); | |
| 118 | + | self.trigger_preview(&hash); | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Toggle loop mode and persist the setting. | |
| 124 | + | pub fn toggle_loop(&mut self) { | |
| 125 | + | self.loop_enabled = !self.loop_enabled; | |
| 126 | + | let _ = self.backend.set_config("preview_loop", if self.loop_enabled { "1" } else { "0" }); | |
| 127 | + | // Sync to the live playback state | |
| 128 | + | self.shared.preview.lock().loop_enabled = self.loop_enabled; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Toggle autoplay mode and persist the setting. | |
| 132 | + | pub fn toggle_autoplay(&mut self) { | |
| 133 | + | self.autoplay = !self.autoplay; | |
| 134 | + | let _ = self.backend.set_config("preview_autoplay", if self.autoplay { "1" } else { "0" }); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // --- Instrument --- | |
| 138 | + | ||
| 139 | + | /// Load a sample for chromatic instrument playback (pitch-shift across the keyboard). | |
| 140 | + | pub fn load_chromatic_sample(&mut self, hash: &str) { | |
| 141 | + | let buf = match self.resolve_and_decode(hash) { | |
| 142 | + | Ok(b) => b, | |
| 143 | + | Err(e) => { | |
| 144 | + | self.status = e; | |
| 145 | + | return; | |
| 146 | + | } | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | // Derive root note from analysis, default to C3 (48) | |
| 150 | + | let root_note = self | |
| 151 | + | .backend | |
| 152 | + | .get_analysis(hash) | |
| 153 | + | .ok() | |
| 154 | + | .flatten() | |
| 155 | + | .and_then(|a| a.musical_key) | |
| 156 | + | .and_then(|k| audiofiles_core::instrument::key_to_root_note(&k)) | |
| 157 | + | .unwrap_or(48); | |
| 158 | + | ||
| 159 | + | let zone = crate::instrument::LoadedZone { | |
| 160 | + | buffer: buf, | |
| 161 | + | root_note, | |
| 162 | + | low_note: 0, | |
| 163 | + | high_note: 127, | |
| 164 | + | vel_low: 0.0, | |
| 165 | + | vel_high: 1.0, | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | let mut inst = self.shared.instrument.lock(); | |
| 169 | + | inst.config.mode = audiofiles_core::instrument::InstrumentMode::Chromatic; | |
| 170 | + | inst.zone_buffers.clear(); | |
| 171 | + | inst.zone_buffers.push(zone); | |
| 172 | + | inst.active = true; | |
| 173 | + | inst.sample_rate = self.sample_rate; | |
| 174 | + | // Kill all voices | |
| 175 | + | for voice in &mut inst.voices { | |
| 176 | + | voice.active = false; | |
| 177 | + | voice.envelope_phase = crate::instrument::EnvelopePhase::Idle; | |
| 178 | + | voice.envelope_level = 0.0; | |
| 179 | + | } | |
| 180 | + | drop(inst); | |
| 181 | + | ||
| 182 | + | self.instrument_root_note = root_note; | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// Toggle instrument mode on/off. | |
| 186 | + | pub fn toggle_instrument(&mut self) { | |
| 187 | + | let mut inst = self.shared.instrument.lock(); | |
| 188 | + | inst.active = !inst.active; | |
| 189 | + | self.instrument_visible = inst.active; | |
| 190 | + | self.show_midi_window = inst.active; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Add a sample as a new zone in multi-sample instrument mode. | |
| 194 | + | pub fn add_instrument_zone(&mut self, hash: &str, name: &str, low: u8, high: u8, root: u8) { | |
| 195 | + | let buf = match self.resolve_and_decode(hash) { | |
| 196 | + | Ok(b) => b, | |
| 197 | + | Err(e) => { | |
| 198 | + | self.status = e; | |
| 199 | + | return; | |
| 200 | + | } | |
| 201 | + | }; | |
| 202 | + | ||
| 203 | + | let zone = crate::instrument::LoadedZone { | |
| 204 | + | buffer: buf, | |
| 205 | + | root_note: root, | |
| 206 | + | low_note: low, | |
| 207 | + | high_note: high, | |
| 208 | + | vel_low: 0.0, | |
| 209 | + | vel_high: 1.0, | |
| 210 | + | }; | |
| 211 | + | ||
| 212 | + | let mut inst = self.shared.instrument.lock(); | |
| 213 | + | inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample; | |
| 214 | + | inst.zone_buffers.push(zone); | |
| 215 | + | inst.active = true; | |
| 216 | + | inst.sample_rate = self.sample_rate; | |
| 217 | + | drop(inst); | |
| 218 | + | ||
| 219 | + | self.instrument_visible = true; | |
| 220 | + | self.show_midi_window = true; | |
| 221 | + | self.status = format!("Added zone: {name} ({}-{})", low, high); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Remove a zone by index and kill any voices using it. | |
| 225 | + | pub fn remove_instrument_zone(&mut self, index: usize) { | |
| 226 | + | let mut inst = self.shared.instrument.lock(); | |
| 227 | + | if index >= inst.zone_buffers.len() { | |
| 228 | + | return; | |
| 229 | + | } | |
| 230 | + | inst.zone_buffers.remove(index); | |
| 231 | + | // Kill voices using this zone or higher indices | |
| 232 | + | for voice in &mut inst.voices { | |
| 233 | + | if voice.active && voice.zone_index == index { | |
| 234 | + | voice.active = false; | |
| 235 | + | voice.envelope_phase = crate::instrument::EnvelopePhase::Idle; | |
| 236 | + | } else if voice.active && voice.zone_index > index { | |
| 237 | + | voice.zone_index -= 1; | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | } |
| @@ -0,0 +1,488 @@ | |||
| 1 | + | //! UI state: selection, sort, and UI-specific type definitions. | |
| 2 | + | ||
| 3 | + | use std::collections::HashSet; | |
| 4 | + | use std::path::PathBuf; | |
| 5 | + | use std::time::Instant; | |
| 6 | + | ||
| 7 | + | use audiofiles_core::edit::{EditOperation, FadeCurve}; | |
| 8 | + | use audiofiles_core::vfs::VfsNode; | |
| 9 | + | use audiofiles_core::{NodeId, VfsId}; | |
| 10 | + | ||
| 11 | + | use crate::import::ImportedFolder; | |
| 12 | + | ||
| 13 | + | /// User preference for how to handle edit results. | |
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 15 | + | pub enum EditResultMode { | |
| 16 | + | /// Replace the VFS node in-place with the edited sample. | |
| 17 | + | Replace, | |
| 18 | + | /// Create a sibling node next to the original. | |
| 19 | + | Sibling, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Data from a completed edit, pending user choice (replace vs sibling). | |
| 23 | + | pub struct PendingEditResult { | |
| 24 | + | pub source_hash: String, | |
| 25 | + | pub result_path: PathBuf, | |
| 26 | + | pub operation: EditOperation, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Pending destructive action awaiting user confirmation. | |
| 30 | + | pub enum ConfirmAction { | |
| 31 | + | DeleteNode { node_id: NodeId, node_name: String }, | |
| 32 | + | DeleteVfs { vfs_id: VfsId, vfs_name: String }, | |
| 33 | + | DeleteMultiple { node_ids: Vec<NodeId>, count: usize }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// An undoable bulk operation. | |
| 37 | + | pub enum UndoOp { | |
| 38 | + | BulkDelete { | |
| 39 | + | nodes: Vec<VfsNode>, | |
| 40 | + | tags: Vec<(String, Vec<String>)>, | |
| 41 | + | }, | |
| 42 | + | BulkMove { | |
| 43 | + | moves: Vec<(NodeId, Option<NodeId>)>, | |
| 44 | + | }, | |
| 45 | + | BulkRename { | |
| 46 | + | renames: Vec<(NodeId, String)>, | |
| 47 | + | }, | |
| 48 | + | BulkTagAdd { | |
| 49 | + | tag: String, | |
| 50 | + | hashes: Vec<String>, | |
| 51 | + | }, | |
| 52 | + | BulkTagRemove { | |
| 53 | + | tag: String, | |
| 54 | + | hashes: Vec<String>, | |
| 55 | + | }, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Active bulk operation modal. | |
| 59 | + | pub enum BulkModal { | |
| 60 | + | /// Bulk add or remove a tag from selected samples. | |
| 61 | + | Tag { | |
| 62 | + | /// The tag string being entered by the user. | |
| 63 | + | tag_input: String, | |
| 64 | + | /// `true` for add, `false` for remove. | |
| 65 | + | adding: bool, | |
| 66 | + | /// Content-addressed hashes of the targeted samples. | |
| 67 | + | hashes: Vec<String>, | |
| 68 | + | /// Display names of the targeted samples. | |
| 69 | + | names: Vec<String>, | |
| 70 | + | }, | |
| 71 | + | /// Bulk move selected nodes to a different directory. | |
| 72 | + | Move { | |
| 73 | + | /// IDs of the VFS nodes being moved. | |
| 74 | + | node_ids: Vec<NodeId>, | |
| 75 | + | /// Display names of the nodes being moved. | |
| 76 | + | names: Vec<String>, | |
| 77 | + | /// All available target directories as `(id, full_path)` pairs. | |
| 78 | + | directories: Vec<(NodeId, String)>, | |
| 79 | + | /// Index into `directories` the user has chosen, or `None` for root. | |
| 80 | + | selected_idx: Option<usize>, | |
| 81 | + | }, | |
| 82 | + | /// Bulk rename selected nodes using a pattern template. | |
| 83 | + | Rename { | |
| 84 | + | /// The rename pattern string (e.g. `"{name}_{bpm}"`). | |
| 85 | + | pattern_input: String, | |
| 86 | + | /// The nodes targeted for rename, with their analysis context. | |
| 87 | + | targets: Vec<RenameTarget>, | |
| 88 | + | /// Live preview pairs of `(old_name, new_name)`. | |
| 89 | + | previews: Vec<(String, String)>, | |
| 90 | + | /// Validation error, if any (empty names, duplicates, bad pattern). | |
| 91 | + | error: Option<String>, | |
| 92 | + | }, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// A rename target with its context for preview. | |
| 96 | + | pub struct RenameTarget { | |
| 97 | + | pub node_id: NodeId, | |
| 98 | + | pub context: audiofiles_core::rename::RenameContext, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Column visibility configuration. | |
| 102 | + | #[derive(serde::Serialize, serde::Deserialize)] | |
| 103 | + | pub struct ColumnConfig { | |
| 104 | + | pub show_classification: bool, | |
| 105 | + | pub show_bpm: bool, | |
| 106 | + | pub show_key: bool, | |
| 107 | + | pub show_duration: bool, | |
| 108 | + | pub show_peak_db: bool, | |
| 109 | + | pub show_tags: bool, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | impl Default for ColumnConfig { | |
| 113 | + | fn default() -> Self { | |
| 114 | + | Self { | |
| 115 | + | show_classification: true, | |
| 116 | + | show_bpm: true, | |
| 117 | + | show_key: true, | |
| 118 | + | show_duration: true, | |
| 119 | + | show_peak_db: false, | |
| 120 | + | show_tags: false, | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// A file that failed during the import phase (before entering the store). | |
| 126 | + | pub struct ImportFileError { | |
| 127 | + | pub path: String, | |
| 128 | + | pub error: String, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// A file that entered the store but failed during analysis. | |
| 132 | + | pub struct AnalysisFileError { | |
| 133 | + | pub hash: String, | |
| 134 | + | pub name: String, | |
| 135 | + | pub error: String, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Status of the sync API key test flow. | |
| 139 | + | pub enum SyncSetupStatus { | |
| 140 | + | /// No test in progress. | |
| 141 | + | Idle, | |
| 142 | + | /// Validation request in flight. | |
| 143 | + | Testing, | |
| 144 | + | /// Key is valid; server returned the app name. | |
| 145 | + | Valid { app_name: String }, | |
| 146 | + | /// Key is invalid or server unreachable. | |
| 147 | + | Failed { error: String }, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Actions the sync setup UI can request from the app layer. | |
| 151 | + | pub enum SyncSetupAction { | |
| 152 | + | /// Validate this API key against the server. | |
| 153 | + | TestKey(String), | |
| 154 | + | /// Save this API key and create a SyncManager. | |
| 155 | + | SaveKey(String), | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Actions the vault picker UI can request from the app layer. | |
| 159 | + | pub enum VaultAction { | |
| 160 | + | /// Switch to a different vault. | |
| 161 | + | SwitchVault(PathBuf), | |
| 162 | + | /// Create a new vault and switch to it. | |
| 163 | + | CreateVault { name: String, path: PathBuf }, | |
| 164 | + | /// Add an existing vault directory to the registry. | |
| 165 | + | AddExistingVault { name: String, path: PathBuf }, | |
| 166 | + | /// Remove a vault from the registry (no file deletion). | |
| 167 | + | RemoveVault(PathBuf), | |
| 168 | + | /// Rename a vault in the registry. | |
| 169 | + | RenameVault { path: PathBuf, new_name: String }, | |
| 170 | + | /// Scan storage stats for the active vault. | |
| 171 | + | ScanStorage, | |
| 172 | + | /// Deactivate the license key and return to activation screen. | |
| 173 | + | DeactivateLicense, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// GUI state for the consolidated Settings window. | |
| 177 | + | #[derive(Default)] | |
| 178 | + | pub struct SettingsUiState { | |
| 179 | + | /// Display name of the active vault. | |
| 180 | + | pub name: String, | |
| 181 | + | /// (name, path, reachable) for each known vault. | |
| 182 | + | pub list: Vec<(String, PathBuf, bool)>, | |
| 183 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 184 | + | pub pending_action: Option<VaultAction>, | |
| 185 | + | /// Whether the Settings window is open. | |
| 186 | + | pub show_manager: bool, | |
| 187 | + | /// Name input for creating/adding a vault. | |
| 188 | + | pub create_name: String, | |
| 189 | + | /// Path input for creating/adding a vault. | |
| 190 | + | pub create_path: Option<PathBuf>, | |
| 191 | + | /// Inline rename: (path, new_name_buffer). | |
| 192 | + | pub rename_target: Option<(PathBuf, String)>, | |
| 193 | + | ||
| 194 | + | /// Cached storage statistics from the last scan. | |
| 195 | + | pub storage_cache: Option<crate::backend::StorageStats>, | |
| 196 | + | /// Whether a storage scan is in progress. | |
| 197 | + | pub storage_scanning: bool, | |
| 198 | + | /// Masked license key for display. | |
| 199 | + | pub license_key_masked: Option<String>, | |
| 200 | + | /// Machine ID for display. | |
| 201 | + | pub machine_id: Option<String>, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// GUI state for the sync setup and panel. | |
| 205 | + | pub struct SyncUiState { | |
| 206 | + | /// Whether the sync panel overlay is open. | |
| 207 | + | pub show_panel: bool, | |
| 208 | + | pub encryption_input: String, | |
| 209 | + | pub auth_code_input: String, | |
| 210 | + | /// API key input for initial setup. | |
| 211 | + | pub api_key_input: String, | |
| 212 | + | /// Status of the API key test flow. | |
| 213 | + | pub setup_status: SyncSetupStatus, | |
| 214 | + | /// Set by the UI, consumed by the app layer each frame. | |
| 215 | + | pub pending_action: Option<SyncSetupAction>, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | impl Default for SyncUiState { | |
| 219 | + | fn default() -> Self { | |
| 220 | + | Self { | |
| 221 | + | show_panel: false, | |
| 222 | + | encryption_input: String::new(), | |
| 223 | + | auth_code_input: String::new(), | |
| 224 | + | api_key_input: String::new(), | |
| 225 | + | setup_status: SyncSetupStatus::Idle, | |
| 226 | + | pending_action: None, | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// GUI state for the floating sample editor window. | |
| 232 | + | pub struct EditUiState { | |
| 233 | + | pub show_window: bool, | |
| 234 | + | pub hash: Option<String>, | |
| 235 | + | pub in_progress: bool, | |
| 236 | + | pub result_prompt: bool, | |
| 237 | + | pub pending_result: Option<PendingEditResult>, | |
| 238 | + | pub trim_start: f32, | |
| 239 | + | pub trim_end: f32, | |
| 240 | + | pub total_frames: usize, | |
| 241 | + | pub gain_db: f64, | |
| 242 | + | pub norm_peak: bool, | |
| 243 | + | pub norm_target: f64, | |
| 244 | + | pub fade_in: bool, | |
| 245 | + | pub fade_duration_ms: f64, | |
| 246 | + | pub fade_curve: FadeCurve, | |
| 247 | + | pub result_mode: Option<EditResultMode>, | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | impl Default for EditUiState { | |
| 251 | + | fn default() -> Self { | |
| 252 | + | Self { | |
| 253 | + | show_window: false, | |
| 254 | + | hash: None, | |
| 255 | + | in_progress: false, | |
| 256 | + | result_prompt: false, | |
| 257 | + | pending_result: None, | |
| 258 | + | trim_start: 0.0, | |
| 259 | + | trim_end: 1.0, | |
| 260 | + | total_frames: 0, | |
| 261 | + | gain_db: 0.0, | |
| 262 | + | norm_peak: true, | |
| 263 | + | norm_target: -0.1, | |
| 264 | + | fade_in: true, | |
| 265 | + | fade_duration_ms: 100.0, | |
| 266 | + | fade_curve: FadeCurve::Linear, | |
| 267 | + | result_mode: None, | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Actions the MIDI setup UI can request from the app layer. | |
| 273 | + | pub enum MidiAction { | |
| 274 | + | /// Connect to the MIDI input port at this index. | |
| 275 | + | Connect(usize), | |
| 276 | + | /// Disconnect the current MIDI input. | |
| 277 | + | Disconnect, | |
| 278 | + | /// Re-enumerate available ports. | |
| 279 | + | RefreshPorts, | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// A recent MIDI note event for the activity display. | |
| 283 | + | pub struct MidiNoteEvent { | |
| 284 | + | pub note: u8, | |
| 285 | + | pub velocity: u8, | |
| 286 | + | pub note_name: String, | |
| 287 | + | pub timestamp: Instant, | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// GUI-side state for the MIDI device picker and activity display. | |
| 291 | + | #[derive(Default)] | |
| 292 | + | pub struct MidiUiState { | |
| 293 | + | pub available_ports: Vec<String>, | |
| 294 | + | pub connected_port: Option<usize>, | |
| 295 | + | pub connected_port_name: Option<String>, | |
| 296 | + | pub recent_notes: Vec<MidiNoteEvent>, | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// A top-level imported folder with a user-editable tag input. | |
| 300 | + | pub struct FolderTagEntry { | |
| 301 | + | pub folder: ImportedFolder, | |
| 302 | + | pub tag_input: String, | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Current import/analysis workflow state. | |
| 306 | + | pub enum ImportMode { | |
| 307 | + | None, | |
| 308 | + | ConfigureImport { | |
| 309 | + | source: PathBuf, | |
| 310 | + | source_name: String, | |
| 311 | + | strategy: crate::import::ImportStrategy, | |
| 312 | + | available_vfs: Vec<audiofiles_core::vfs::Vfs>, | |
| 313 | + | selected_merge_vfs_idx: usize, | |
| 314 | + | new_vfs_name: String, | |
| 315 | + | }, | |
| 316 | + | Importing { | |
| 317 | + | total: usize, | |
| 318 | + | completed: usize, | |
| 319 | + | current_name: String, | |
| 320 | + | walking: bool, | |
| 321 | + | }, | |
| 322 | + | TagFolders { | |
| 323 | + | entries: Vec<FolderTagEntry>, | |
| 324 | + | sample_hashes: Vec<(String, String)>, | |
| 325 | + | }, | |
| 326 | + | ConfigureAnalysis { | |
| 327 | + | sample_hashes: Vec<(String, String)>, | |
| 328 | + | config: audiofiles_core::analysis::config::AnalysisConfig, | |
| 329 | + | }, | |
| 330 | + | Analyzing { | |
| 331 | + | completed: usize, | |
| 332 | + | total: usize, | |
| 333 | + | current_name: String, | |
| 334 | + | }, | |
| 335 | + | ReviewSuggestions { | |
| 336 | + | items: Vec<ReviewItem>, | |
| 337 | + | current_idx: usize, | |
| 338 | + | }, | |
| 339 | + | ConfigureExport { | |
| 340 | + | items: Vec<audiofiles_core::export::ExportItem>, | |
| 341 | + | config: audiofiles_core::export::ExportConfig, | |
| 342 | + | /// Available device profiles for the profile picker. | |
| 343 | + | available_profiles: Vec<audiofiles_core::export::profile::DeviceProfileSummary>, | |
| 344 | + | }, | |
| 345 | + | Exporting { | |
| 346 | + | completed: usize, | |
| 347 | + | total: usize, | |
| 348 | + | current_name: String, | |
| 349 | + | }, | |
| 350 | + | ExportComplete { | |
| 351 | + | total: usize, | |
| 352 | + | errors: Vec<(String, String)>, | |
| 353 | + | }, | |
| 354 | + | ReviewErrors, | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /// A sample with its analysis results and pending tag suggestions. | |
| 358 | + | pub struct ReviewItem { | |
| 359 | + | pub hash: audiofiles_core::SampleHash, | |
| 360 | + | pub name: String, | |
| 361 | + | pub result: audiofiles_core::analysis::AnalysisResult, | |
| 362 | + | pub suggestions: Vec<SuggestionState>, | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// A tag suggestion the user can accept or reject. | |
| 366 | + | pub struct SuggestionState { | |
| 367 | + | pub suggestion: audiofiles_core::analysis::suggest::TagSuggestion, | |
| 368 | + | pub accepted: bool, | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // --- Sort --- | |
| 372 | + | ||
| 373 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 374 | + | pub enum SortColumn { | |
| 375 | + | Name, | |
| 376 | + | Bpm, | |
| 377 | + | Key, | |
| 378 | + | Duration, | |
| 379 | + | Classification, | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 383 | + | pub enum SortDirection { | |
| 384 | + | Ascending, | |
| 385 | + | Descending, | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // --- Selection --- | |
| 389 | + | ||
| 390 | + | /// Multi-selection state with anchor-based range selection. | |
| 391 | + | /// | |
| 392 | + | /// Three indices work together: `anchor` is where a shift-click range starts, | |
| 393 | + | /// `focus` tracks the most recently interacted-with row (the cursor), | |
| 394 | + | /// and `selected` is the full set of selected row indices. A plain click | |
| 395 | + | /// sets all three to the same row; shift-click extends from anchor to the | |
| 396 | + | /// clicked row; cmd-click toggles one row without moving the anchor. | |
| 397 | + | #[derive(Debug, Clone, Default)] | |
| 398 | + | pub struct Selection { | |
| 399 | + | /// The anchor point for shift-click range selection. | |
| 400 | + | pub anchor: usize, | |
| 401 | + | /// The focused (most recently selected) item. | |
| 402 | + | pub focus: usize, | |
| 403 | + | /// Set of selected indices. | |
| 404 | + | pub selected: HashSet<usize>, | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | impl Selection { | |
| 408 | + | /// Create an empty selection with anchor and focus at index 0. | |
| 409 | + | pub fn new() -> Self { | |
| 410 | + | Self::default() | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | /// Clear the selection and reset anchor/focus to 0. | |
| 414 | + | pub fn clear(&mut self) { | |
| 415 | + | self.selected.clear(); | |
| 416 | + | self.anchor = 0; | |
| 417 | + | self.focus = 0; | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | /// Single-select one item, clearing all others. | |
| 421 | + | pub fn set_single(&mut self, idx: usize) { | |
| 422 | + | self.selected.clear(); | |
| 423 | + | self.selected.insert(idx); | |
| 424 | + | self.anchor = idx; | |
| 425 | + | self.focus = idx; | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | /// Toggle an item in the selection (Cmd+Click). | |
| 429 | + | pub fn toggle(&mut self, idx: usize) { | |
| 430 | + | if self.selected.contains(&idx) { | |
| 431 | + | self.selected.remove(&idx); | |
| 432 | + | } else { | |
| 433 | + | self.selected.insert(idx); | |
| 434 | + | } | |
| 435 | + | self.anchor = idx; | |
| 436 | + | self.focus = idx; | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// Extend selection from anchor to target (Shift+Click). | |
| 440 | + | /// `_max_len` is accepted for API consistency but unused — the range is | |
| 441 | + | /// always anchor..=target regardless of list length. | |
| 442 | + | pub fn extend_to(&mut self, target: usize, _max_len: usize) { | |
| 443 | + | let start = self.anchor.min(target); | |
| 444 | + | let end = self.anchor.max(target); | |
| 445 | + | for i in start..=end { | |
| 446 | + | self.selected.insert(i); | |
| 447 | + | } | |
| 448 | + | self.focus = target; | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | /// Extend selection down by one (Shift+Down). | |
| 452 | + | pub fn extend_down(&mut self, max_len: usize) { | |
| 453 | + | if max_len > 0 && self.focus < max_len - 1 { | |
| 454 | + | self.focus += 1; | |
| 455 | + | self.selected.insert(self.focus); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /// Extend selection up by one (Shift+Up). | |
| 460 | + | pub fn extend_up(&mut self) { | |
| 461 | + | if self.focus > 0 { | |
| 462 | + | self.focus -= 1; | |
| 463 | + | self.selected.insert(self.focus); | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | /// Select all items. | |
| 468 | + | pub fn select_all(&mut self, len: usize) { | |
| 469 | + | self.selected.clear(); | |
| 470 | + | for i in 0..len { | |
| 471 | + | self.selected.insert(i); | |
| 472 | + | } | |
| 473 | + | if len > 0 { | |
| 474 | + | self.anchor = 0; | |
| 475 | + | self.focus = len - 1; | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// Check whether `idx` is in the current selection. | |
| 480 | + | pub fn contains(&self, idx: usize) -> bool { | |
| 481 | + | self.selected.contains(&idx) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | /// Number of currently selected items. | |
| 485 | + | pub fn count(&self) -> usize { | |
| 486 | + | self.selected.len() | |
| 487 | + | } | |
| 488 | + | } |
| @@ -73,7 +73,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | ui.add_space(8.0); | |
| 76 | + | ui.add_space(12.0); | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | 79 | // Sample name | |
| @@ -142,8 +142,9 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 142 | 142 | }); | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | - | ui.add_space(8.0); | |
| 145 | + | ui.add_space(12.0); | |
| 146 | 146 | ui.separator(); | |
| 147 | + | ui.add_space(4.0); | |
| 147 | 148 | ||
| 148 | 149 | // Tags section | |
| 149 | 150 | ui.label(egui::RichText::new("Tags").color(theme::text_secondary())); | |
| @@ -189,7 +190,9 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 189 | 190 | } | |
| 190 | 191 | }); | |
| 191 | 192 | ||
| 192 | - | ui.add_space(8.0); | |
| 193 | + | ui.add_space(12.0); | |
| 194 | + | ui.separator(); | |
| 195 | + | ui.add_space(4.0); | |
| 193 | 196 | ||
| 194 | 197 | // Action buttons | |
| 195 | 198 | ui.horizontal(|ui| { |
| @@ -57,7 +57,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 57 | 57 | return; | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | let row_height = 24.0; | |
| 60 | + | let row_height = state.row_height; | |
| 61 | 61 | let has_parent = state.current_dir.is_some(); | |
| 62 | 62 | let contents = state.contents.clone(); | |
| 63 | 63 | let offset = if has_parent { 1usize } else { 0 }; |
| @@ -125,6 +125,40 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt | |||
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | // Analysis coverage indicator | |
| 129 | + | { | |
| 130 | + | let total_samples = state.contents.iter().filter(|n| n.node.sample_hash.is_some()).count(); | |
| 131 | + | if total_samples > 0 { | |
| 132 | + | let analyzed = state.contents.iter().filter(|n| n.node.sample_hash.is_some() && n.duration.is_some()).count(); | |
| 133 | + | let untagged = state.contents.iter() | |
| 134 | + | .filter(|n| n.node.sample_hash.is_some() && n.tags.is_empty()) | |
| 135 | + | .count(); | |
| 136 | + | ||
| 137 | + | ui.separator(); | |
| 138 | + | if analyzed < total_samples { | |
| 139 | + | ui.label( | |
| 140 | + | egui::RichText::new(format!("{analyzed}/{total_samples} analyzed")) | |
| 141 | + | .small() | |
| 142 | + | .color(theme::text_muted()), | |
| 143 | + | ); | |
| 144 | + | } else { | |
| 145 | + | ui.label( | |
| 146 | + | egui::RichText::new(format!("\u{2713} {total_samples} analyzed")) | |
| 147 | + | .small() | |
| 148 | + | .color(theme::accent_green()), | |
| 149 | + | ); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | if untagged > 0 { | |
| 153 | + | ui.label( | |
| 154 | + | egui::RichText::new(format!("\u{00B7} {untagged} untagged")) | |
| 155 | + | .small() | |
| 156 | + | .color(theme::text_muted()), | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 128 | 162 | // Status message | |
| 129 | 163 | if !state.status.is_empty() { | |
| 130 | 164 | ui.separator(); |
| @@ -201,12 +201,26 @@ pub fn draw_configure_analysis(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 201 | 201 | ui.checkbox(&mut config.auto_suggest_tags, "Auto-suggest Tags"); | |
| 202 | 202 | ui.checkbox(&mut config.fingerprint, "Fingerprint (duplicate detection)"); | |
| 203 | 203 | ||
| 204 | + | if config.classify { | |
| 205 | + | ui.indent("smart_skip_indent", |ui| { | |
| 206 | + | ui.checkbox( | |
| 207 | + | &mut config.smart_skip, | |
| 208 | + | "Smart skip (skip BPM/key for drums, noise, etc.)", | |
| 209 | + | ); | |
| 210 | + | }); | |
| 211 | + | } | |
| 212 | + | ||
| 204 | 213 | // Classification depends on spectral features (centroid, flatness, ZCR, etc.), | |
| 205 | 214 | // so force spectral on when classify is checked. | |
| 206 | 215 | if config.classify && !config.spectral { | |
| 207 | 216 | config.spectral = true; | |
| 208 | 217 | } | |
| 209 | 218 | ||
| 219 | + | // Smart skip requires classification | |
| 220 | + | if !config.classify { | |
| 221 | + | config.smart_skip = false; | |
| 222 | + | } | |
| 223 | + | ||
| 210 | 224 | ui.add_space(16.0); | |
| 211 | 225 | ||
| 212 | 226 | ui.horizontal(|ui| { |