Skip to main content

max / audiofiles

v0.3.3: 7-class drum classifier, smart skip, maintainability splits Classification pipeline rework: - Expand drum classifier from 5 to 7 classes (add clap, tom) - Tighten Layer 1 broad classifier rules to prevent drum leaks - Retrain Layer 2 RF model (87.3% CV accuracy, 3.9MB) - Add smart skip: gate BPM/key/loop detection by classification - Add benchmark binary (audiofiles-bench) for model evaluation Maintainability splits: - browser state: mod.rs → library.rs, playback.rs, ui.rs - sync service: service.rs → service/{mod,state,download,upload,resolve}.rs - export: add dither.rs module UI polish: - Smart skip checkbox (under classify toggle on import config) - Improved drag-out safety comments (macOS/Windows FFI) - Footer, sidebar, settings panel, theme, detail panel refinements Docs: add database_schema.md, free_sample_sources.md, ml_classifier.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-06 21:26 UTC
Commit: 3b57d9ebf5d30e35aee984a48c22a6120c89a9d7
Parent: 0417eb9
56 files changed, +4284 insertions, -2065 deletions
M .gitignore +2
@@ -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
M Cargo.lock +10
@@ -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 = [
M Cargo.toml +1 -1
@@ -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
M README.md +62 -15
@@ -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(&current) {
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| {
M docs/todo.md +104 -2