Skip to main content

max / audiofiles

Add license key activation gate App requires a valid MNW license key before the browser is accessible. Activation cached locally in license.json for offline use. Deactivation releases the machine slot on the server (best-effort). Docs updates: audit_history split out, changelog, plugin authoring guide, competition and description refreshed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-31 16:31 UTC
Commit: 590a1365af290c480be8ae8d6fd2eb6983805a4a
Parent: 547ecd7
12 files changed, +1548 insertions, -296 deletions
A CHANGELOG.md +33
@@ -0,0 +1,33 @@
1 + # Changelog
2 +
3 + All notable changes to audiofiles will be documented in this file.
4 +
5 + Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 +
7 + ## [0.3.0] — 2026-03-28
8 +
9 + First beta-ready release.
10 +
11 + ### Added
12 + - Cloud sync via SyncKit SDK (sample metadata, tags, VFS mappings)
13 + - OTA updates via custom reqwest-based checker
14 + - ML-based sample classification (two-layer system, 94.4% accuracy on drums)
15 + - VP-tree similarity search across sample library
16 + - Content-addressed blob store (SHA-256)
17 + - Virtual filesystem for sample organization
18 + - Device-aware export with 14 bundled sampler profiles
19 + - Rhai plugin system for custom device profiles
20 + - Drag-to-DAW export (macOS and Windows)
21 + - Waveform visualization
22 + - Collections for organizing samples
23 + - Audio analysis: spectral features, BPM, key detection, MFCC extraction
24 + - 16 bundled color themes
25 + - macOS, Windows, and Linux builds
26 +
27 + ### Fixed
28 + - All issues identified in audit runs 1–12
29 + - Concurrency and type safety patterns (Rust patterns audit)
30 +
31 + ### Security
32 + - Full observability instrumentation (115 traced functions)
33 + - 17 justified FFI unsafe blocks for platform drag-and-drop (macOS objc2 + Windows COM)
M Cargo.lock +2
@@ -385,6 +385,7 @@ dependencies = [
385 385 "audiofiles-browser",
386 386 "audiofiles-core",
387 387 "audiofiles-sync",
388 + "chrono",
388 389 "cpal",
389 390 "dirs",
390 391 "eframe",
@@ -401,6 +402,7 @@ dependencies = [
401 402 "tracing",
402 403 "tracing-subscriber",
403 404 "tray-icon",
405 + "uuid",
404 406 ]
405 407
406 408 [[package]]
@@ -21,6 +21,8 @@ semver = { workspace = true }
21 21 serde = { workspace = true }
22 22 serde_json = { workspace = true }
23 23 open = { workspace = true }
24 + uuid = { workspace = true }
25 + chrono = { workspace = true }
24 26
25 27 [dev-dependencies]
26 28 tempfile = "3"
@@ -0,0 +1,294 @@
1 + //! License key activation for audiofiles standalone app.
2 + //!
3 + //! Manages machine identity, license caching, and activation/deactivation
4 + //! against the MNW license key API. Once activated, the result is cached
5 + //! locally so the app works offline indefinitely.
6 +
7 + use std::io;
8 + use std::path::Path;
9 + use std::sync::Arc;
10 +
11 + use parking_lot::Mutex;
12 + use serde::{Deserialize, Serialize};
13 +
14 + /// Cached license data, persisted to `license.json`.
15 + #[derive(Debug, Clone, Serialize, Deserialize)]
16 + pub struct LicenseCache {
17 + pub key_code: String,
18 + pub machine_id: String,
19 + pub activated_at: String,
20 + }
21 +
22 + /// Whether the app has a valid cached license.
23 + pub enum LicenseStatus {
24 + Unlicensed,
25 + Licensed(LicenseCache),
26 + }
27 +
28 + /// Shared slot for async activation results, polled each frame.
29 + pub type ActivationResult = Arc<Mutex<Option<Result<(), String>>>>;
30 +
31 + // ── API request/response types ──
32 +
33 + #[derive(Serialize)]
34 + struct ValidateRequest<'a> {
35 + key: &'a str,
36 + machine_id: &'a str,
37 + label: Option<&'a str>,
38 + }
39 +
40 + #[derive(Deserialize)]
41 + struct ValidateResponse {
42 + valid: bool,
43 + #[serde(default)]
44 + error: Option<String>,
45 + }
46 +
47 + #[derive(Serialize)]
48 + struct DeactivateRequest<'a> {
49 + key: &'a str,
50 + machine_id: &'a str,
51 + }
52 +
53 + #[derive(Deserialize)]
54 + struct DeactivateResponse {
55 + success: bool,
56 + #[allow(dead_code)]
57 + message: String,
58 + }
59 +
60 + // ── Machine identity ──
61 +
62 + /// Read or create a stable machine ID (UUIDv4) for this installation.
63 + pub fn get_or_create_machine_id(data_dir: &Path) -> String {
64 + let path = data_dir.join("machine_id");
65 + if let Ok(id) = std::fs::read_to_string(&path) {
66 + let id = id.trim().to_string();
67 + if !id.is_empty() {
68 + return id;
69 + }
70 + }
71 + let id = uuid::Uuid::new_v4().to_string();
72 + let _ = std::fs::create_dir_all(data_dir);
73 + if let Err(e) = std::fs::write(&path, &id) {
74 + tracing::error!("Failed to write machine_id to {}: {e}", path.display());
75 + }
76 + id
77 + }
78 +
79 + // ── License file I/O ──
80 +
81 + /// Load a cached license from disk. Returns `Unlicensed` if missing or corrupt.
82 + pub fn load_license(data_dir: &Path) -> LicenseStatus {
83 + let path = data_dir.join("license.json");
84 + let bytes = match std::fs::read(&path) {
85 + Ok(b) => b,
86 + Err(_) => return LicenseStatus::Unlicensed,
87 + };
88 + match serde_json::from_slice::<LicenseCache>(&bytes) {
89 + Ok(cache) => LicenseStatus::Licensed(cache),
90 + Err(e) => {
91 + tracing::warn!("Corrupt license.json, treating as unlicensed: {e}");
92 + LicenseStatus::Unlicensed
93 + }
94 + }
95 + }
96 +
97 + /// Write a license cache to disk.
98 + pub fn save_license(data_dir: &Path, cache: &LicenseCache) -> io::Result<()> {
99 + let path = data_dir.join("license.json");
100 + let json = serde_json::to_string_pretty(cache)
101 + .map_err(io::Error::other)?;
102 + std::fs::write(&path, json)
103 + }
104 +
105 + /// Remove the cached license file.
106 + pub fn remove_license(data_dir: &Path) -> io::Result<()> {
107 + let path = data_dir.join("license.json");
108 + match std::fs::remove_file(&path) {
109 + Ok(()) => Ok(()),
110 + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
111 + Err(e) => Err(e),
112 + }
113 + }
114 +
115 + // ── HTTP activation/deactivation ──
116 +
117 + /// Activate a license key against the MNW API.
118 + pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> {
119 + let client = reqwest::Client::builder()
120 + .timeout(std::time::Duration::from_secs(15))
121 + .build()
122 + .map_err(|e| format!("HTTP client error: {e}"))?;
123 +
124 + let url = format!("{server_url}/api/keys/validate");
125 + let body = ValidateRequest {
126 + key,
127 + machine_id,
128 + label: Some("audiofiles"),
129 + };
130 +
131 + let resp = client
132 + .post(&url)
133 + .json(&body)
134 + .send()
135 + .await
136 + .map_err(|e| format!("Network error: {e}"))?;
137 +
138 + if !resp.status().is_success() {
139 + return Err(format!("Server returned {}", resp.status()));
140 + }
141 +
142 + let parsed: ValidateResponse = resp
143 + .json()
144 + .await
145 + .map_err(|e| format!("Invalid response: {e}"))?;
146 +
147 + if parsed.valid {
148 + Ok(())
149 + } else {
150 + let msg = parsed.error.unwrap_or_else(|| "Invalid key".to_string());
151 + Err(msg)
152 + }
153 + }
154 +
155 + /// Deactivate a license key (best-effort, fire-and-forget).
156 + pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> {
157 + let client = reqwest::Client::builder()
158 + .timeout(std::time::Duration::from_secs(15))
159 + .build()
160 + .map_err(|e| format!("HTTP client error: {e}"))?;
161 +
162 + let url = format!("{server_url}/api/keys/deactivate");
163 + let body = DeactivateRequest { key, machine_id };
164 +
165 + let resp = client
166 + .post(&url)
167 + .json(&body)
168 + .send()
169 + .await
170 + .map_err(|e| format!("Network error: {e}"))?;
171 +
172 + if !resp.status().is_success() {
173 + return Err(format!("Server returned {}", resp.status()));
174 + }
175 +
176 + let parsed: DeactivateResponse = resp
177 + .json()
178 + .await
179 + .map_err(|e| format!("Invalid response: {e}"))?;
180 +
181 + if parsed.success {
182 + Ok(())
183 + } else {
184 + Err(parsed.message)
185 + }
186 + }
187 +
188 + #[cfg(test)]
189 + mod tests {
190 + use super::*;
191 +
192 + #[test]
193 + fn machine_id_created_and_idempotent() {
194 + let dir = tempfile::tempdir().unwrap();
195 + let id1 = get_or_create_machine_id(dir.path());
196 + let id2 = get_or_create_machine_id(dir.path());
197 + assert_eq!(id1, id2);
198 + assert!(!id1.is_empty());
199 + // Should be a valid UUID
200 + assert!(uuid::Uuid::parse_str(&id1).is_ok());
201 + }
202 +
203 + #[test]
204 + fn machine_id_creates_data_dir() {
205 + let dir = tempfile::tempdir().unwrap();
206 + let nested = dir.path().join("sub").join("dir");
207 + let id = get_or_create_machine_id(&nested);
208 + assert!(!id.is_empty());
209 + assert!(nested.join("machine_id").exists());
210 + }
211 +
212 + #[test]
213 + fn save_load_license_roundtrip() {
214 + let dir = tempfile::tempdir().unwrap();
215 + let cache = LicenseCache {
216 + key_code: "bright-castle-forest-river-falcon".to_string(),
217 + machine_id: "test-machine".to_string(),
218 + activated_at: "2026-03-30T12:00:00Z".to_string(),
219 + };
220 + save_license(dir.path(), &cache).unwrap();
221 + match load_license(dir.path()) {
222 + LicenseStatus::Licensed(loaded) => {
223 + assert_eq!(loaded.key_code, cache.key_code);
224 + assert_eq!(loaded.machine_id, cache.machine_id);
225 + assert_eq!(loaded.activated_at, cache.activated_at);
226 + }
227 + LicenseStatus::Unlicensed => panic!("Expected Licensed"),
228 + }
229 + }
230 +
231 + #[test]
232 + fn load_missing_returns_unlicensed() {
233 + let dir = tempfile::tempdir().unwrap();
234 + assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed));
235 + }
236 +
237 + #[test]
238 + fn load_corrupt_returns_unlicensed() {
239 + let dir = tempfile::tempdir().unwrap();
240 + std::fs::write(dir.path().join("license.json"), "not json{{{").unwrap();
241 + assert!(matches!(load_license(dir.path()), LicenseStatus::Unlicensed));
242 + }
243 +
244 + #[test]
245 + fn remove_license_deletes_file() {
246 + let dir = tempfile::tempdir().unwrap();
247 + let cache = LicenseCache {
248 + key_code: "test".to_string(),
249 + machine_id: "m".to_string(),
250 + activated_at: "now".to_string(),
251 + };
252 + save_license(dir.path(), &cache).unwrap();
253 + assert!(dir.path().join("license.json").exists());
254 + remove_license(dir.path()).unwrap();
255 + assert!(!dir.path().join("license.json").exists());
256 + }
257 +
258 + #[test]
259 + fn remove_license_missing_is_ok() {
260 + let dir = tempfile::tempdir().unwrap();
261 + assert!(remove_license(dir.path()).is_ok());
262 + }
263 +
264 + #[test]
265 + fn validate_response_deserializes_success() {
266 + let json = r#"{"valid": true}"#;
267 + let resp: ValidateResponse = serde_json::from_str(json).unwrap();
268 + assert!(resp.valid);
269 + assert!(resp.error.is_none());
270 + }
271 +
272 + #[test]
273 + fn validate_response_deserializes_failure() {
274 + let json = r#"{"valid": false, "error": "invalid_key"}"#;
275 + let resp: ValidateResponse = serde_json::from_str(json).unwrap();
276 + assert!(!resp.valid);
277 + assert_eq!(resp.error.as_deref(), Some("invalid_key"));
278 + }
279 +
280 + #[test]
281 + fn validate_response_ignores_extra_fields() {
282 + let json = r#"{"valid": true, "activated": true, "license": {"item_id": "abc", "max_activations": 5, "activation_count": 1, "created_at": "2026-01-01T00:00:00Z"}}"#;
283 + let resp: ValidateResponse = serde_json::from_str(json).unwrap();
284 + assert!(resp.valid);
285 + }
286 +
287 + #[test]
288 + fn deactivate_response_deserializes() {
289 + let json = r#"{"success": true, "message": "deactivated"}"#;
290 + let resp: DeactivateResponse = serde_json::from_str(json).unwrap();
291 + assert!(resp.success);
292 + assert_eq!(resp.message, "deactivated");
293 + }
294 + }
@@ -1,9 +1,12 @@
1 1 //! audiofiles standalone desktop app.
2 2 //!
3 3 //! Launches an eframe window with the shared egui browser UI and a cpal audio
4 - //! output stream for sample preview playback.
4 + //! output stream for sample preview playback. Requires a valid license key
5 + //! before the browser is accessible — the activation result is cached locally
6 + //! so the app works offline after the first activation.
5 7
6 8 mod audio;
9 + mod license;
7 10 mod tray;
8 11 pub mod updater;
9 12
@@ -51,9 +54,6 @@ fn main() -> eframe::Result<()> {
51 54 .build()
52 55 .expect("failed to start tokio runtime");
53 56
54 - // SyncManager (optional — loaded from saved key or env var)
55 - let sync_manager = create_sync_manager(&data_dir, runtime.handle());
56 -
57 57 // OTA update checker (runs in background on the tokio runtime)
58 58 let update_checker = updater::UpdateChecker::new(runtime.handle());
59 59
@@ -99,7 +99,7 @@ fn main() -> eframe::Result<()> {
99 99 Box::new(move |cc| {
100 100 audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx);
101 101 Ok(Box::new(AudioFilesApp::new(
102 - data_dir, shared, app_tray, sync_manager, update_checker, runtime, gtk_ok,
102 + data_dir, shared, app_tray, update_checker, runtime, gtk_ok,
103 103 )))
104 104 }),
105 105 )
@@ -222,10 +222,20 @@ mod tests {
222 222
223 223 // ── App ──
224 224
225 + /// Which screen the app is showing.
226 + enum AppScreen {
227 + /// License activation gate — no browser access until a valid key is entered.
228 + Activation,
229 + /// Normal browser UI.
230 + Browser,
231 + }
232 +
225 233 struct AudioFilesApp {
234 + screen: AppScreen,
226 235 browser: Option<BrowserState>,
227 236 error: Option<String>,
228 237 data_dir: PathBuf,
238 + shared: Arc<SharedState>,
229 239 tray: Option<tray::AppTray>,
230 240 sync_manager: Option<SyncManager>,
231 241 update_checker: updater::UpdateChecker,
@@ -234,6 +244,15 @@ struct AudioFilesApp {
234 244 #[allow(dead_code)]
235 245 gtk_ok: bool,
236 246 _runtime: tokio::runtime::Runtime,
247 +
248 + // ── License activation state ──
249 + machine_id: String,
250 + license_key_input: String,
251 + activation_result: license::ActivationResult,
252 + activation_error: Option<String>,
253 + activating: bool,
254 + show_license_info: bool,
255 + license_cache: Option<license::LicenseCache>,
237 256 }
238 257
239 258 impl AudioFilesApp {
@@ -241,48 +260,221 @@ impl AudioFilesApp {
241 260 data_dir: PathBuf,
242 261 shared: Arc<SharedState>,
243 262 tray: Option<tray::AppTray>,
244 - sync_manager: Option<SyncManager>,
245 263 update_checker: updater::UpdateChecker,
246 264 runtime: tokio::runtime::Runtime,
247 265 gtk_ok: bool,
248 266 ) -> Self {
249 - let sample_rate = 44100.0;
250 -
251 - match BrowserState::new(&data_dir, shared, sample_rate) {
252 - Ok(mut browser) => {
253 - // Import any files/directories passed as CLI arguments
254 - for arg in std::env::args().skip(1) {
255 - let path = PathBuf::from(&arg);
256 - if path.exists() {
257 - browser.import_path(&path);
267 + let _ = std::fs::create_dir_all(&data_dir);
268 + let machine_id = license::get_or_create_machine_id(&data_dir);
269 + let license_status = license::load_license(&data_dir);
270 +
271 + let (screen, browser, error, sync_manager, license_cache) = match license_status {
272 + license::LicenseStatus::Licensed(cache) => {
273 + let sync_manager = create_sync_manager(&data_dir, runtime.handle());
274 + let (browser, error) = init_browser(&data_dir, shared.clone());
275 + (AppScreen::Browser, browser, error, sync_manager, Some(cache))
276 + }
277 + license::LicenseStatus::Unlicensed => {
278 + tracing::info!("No valid license found, showing activation screen");
279 + (AppScreen::Activation, None, None, None, None)
280 + }
281 + };
282 +
283 + Self {
284 + screen,
285 + browser,
286 + error,
287 + data_dir,
288 + shared,
289 + tray,
290 + sync_manager,
291 + update_checker,
292 + sync_test_result: Arc::new(Mutex::new(None)),
293 + gtk_ok,
294 + _runtime: runtime,
295 + machine_id,
296 + license_key_input: String::new(),
297 + activation_result: Arc::new(Mutex::new(None)),
298 + activation_error: None,
299 + activating: false,
300 + show_license_info: false,
301 + license_cache,
302 + }
303 + }
304 +
305 + /// Initialise the browser after successful activation.
306 + fn activate_browser(&mut self) {
307 + self.sync_manager = create_sync_manager(&self.data_dir, self._runtime.handle());
308 + let (browser, error) = init_browser(&self.data_dir, self.shared.clone());
309 + self.browser = browser;
310 + self.error = error;
311 + self.screen = AppScreen::Browser;
312 + }
313 +
314 + /// Deactivate the license: notify the server (best-effort), delete the
315 + /// local cache, and return to the activation screen.
316 + fn deactivate(&mut self) {
317 + if let Some(ref cache) = self.license_cache {
318 + let server_url = SYNC_SERVER_URL.to_string();
319 + let key = cache.key_code.clone();
320 + let mid = self.machine_id.clone();
321 + self._runtime.spawn(async move {
322 + if let Err(e) = license::deactivate_key(&server_url, &key, &mid).await {
323 + tracing::warn!("Deactivation request failed (best-effort): {e}");
324 + }
325 + });
326 + }
327 + let _ = license::remove_license(&self.data_dir);
328 + self.license_cache = None;
329 + self.browser = None;
330 + self.sync_manager = None;
331 + self.screen = AppScreen::Activation;
332 + self.license_key_input.clear();
333 + self.activation_error = None;
334 + }
335 +
336 + /// Draw the license activation screen.
337 + fn draw_activation_screen(&mut self, ctx: &egui::Context) {
338 + // Poll async activation result (take from lock, then drop guard before mutating self)
339 + let activation = self.activation_result.lock().take();
340 + if let Some(result) = activation {
341 + match result {
342 + Ok(()) => {
343 + let cache = license::LicenseCache {
344 + key_code: self.license_key_input.trim().to_string(),
345 + machine_id: self.machine_id.clone(),
346 + activated_at: chrono::Utc::now().to_rfc3339(),
347 + };
348 + if let Err(e) = license::save_license(&self.data_dir, &cache) {
349 + tracing::error!("Failed to save license: {e}");
258 350 }
351 + self.license_cache = Some(cache);
352 + self.activating = false;
353 + self.activation_error = None;
354 + self.activate_browser();
355 + return;
259 356 }
260 - Self {
261 - browser: Some(browser),
262 - error: None,
263 - data_dir,
264 - tray,
265 - sync_manager,
266 - update_checker,
267 - sync_test_result: Arc::new(Mutex::new(None)),
268 - gtk_ok,
269 - _runtime: runtime,
357 + Err(e) => {
358 + self.activation_error = Some(e);
359 + self.activating = false;
270 360 }
271 361 }
272 - Err(e) => {
273 - tracing::error!("Failed to init browser: {e}");
274 - Self {
275 - browser: None,
276 - error: Some(format!("{e}")),
277 - data_dir,
278 - tray,
279 - sync_manager,
280 - update_checker,
281 - sync_test_result: Arc::new(Mutex::new(None)),
282 - gtk_ok,
283 - _runtime: runtime,
362 + }
363 +
364 + egui::CentralPanel::default().show(ctx, |ui| {
365 + let available = ui.available_size();
366 + ui.add_space((available.y * 0.35).max(40.0));
367 +
368 + ui.vertical_centered(|ui| {
369 + ui.heading("audiofiles");
370 + ui.add_space(8.0);
371 + ui.label("Enter your license key to get started.");
372 + ui.add_space(16.0);
373 +
374 + // Key input field (fixed width, centered)
375 + let input_width = 360.0_f32.min(available.x - 40.0);
376 + ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| {
377 + let response = ui.add_sized(
378 + ui.available_size(),
379 + egui::TextEdit::singleline(&mut self.license_key_input)
380 + .hint_text("bright-castle-forest-river-falcon"),
381 + );
382 + // Submit on Enter
383 + if response.lost_focus()
384 + && ui.input(|i| i.key_pressed(egui::Key::Enter))
385 + && !self.activating
386 + && !self.license_key_input.trim().is_empty()
387 + {
388 + self.start_activation();
389 + }
390 + });
391 +
392 + ui.add_space(8.0);
393 +
394 + let can_activate = !self.activating && !self.license_key_input.trim().is_empty();
395 + let button_text = if self.activating { "Activating..." } else { "Activate" };
396 + if ui.add_enabled(can_activate, egui::Button::new(button_text)).clicked() {
397 + self.start_activation();
398 + }
399 +
400 + if let Some(ref err) = self.activation_error {
401 + ui.add_space(8.0);
402 + ui.colored_label(egui::Color32::from_rgb(220, 60, 60), err);
403 + }
404 +
405 + ui.add_space(16.0);
406 + ui.hyperlink_to(
407 + "Get a license key",
408 + "https://makenot.work/store/audiofiles",
409 + );
410 + });
411 + });
412 + }
413 +
414 + /// Spawn the async activation request.
415 + fn start_activation(&mut self) {
416 + self.activating = true;
417 + self.activation_error = None;
418 + let slot = self.activation_result.clone();
419 + let server_url = SYNC_SERVER_URL.to_string();
420 + let key = self.license_key_input.trim().to_string();
421 + let mid = self.machine_id.clone();
422 + self._runtime.spawn(async move {
423 + let result = license::activate_key(&server_url, &key, &mid).await;
424 + *slot.lock() = Some(result);
425 + });
426 + }
427 +
428 + /// Draw the license info overlay (small window with masked key + deactivate).
429 + fn draw_license_info(&mut self, ctx: &egui::Context) {
430 + let mut open = self.show_license_info;
431 + egui::Window::new("License")
432 + .open(&mut open)
433 + .resizable(false)
434 + .collapsible(false)
435 + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
436 + .show(ctx, |ui| {
437 + if let Some(ref cache) = self.license_cache {
438 + let masked = mask_key(&cache.key_code);
439 + ui.label(format!("Key: {masked}"));
440 + ui.label(format!("Machine: {}...{}", &self.machine_id[..8], &self.machine_id[self.machine_id.len()-4..]));
441 + ui.add_space(8.0);
442 + if ui.button("Deactivate").clicked() {
443 + self.deactivate();
444 + }
445 + }
446 + });
447 + self.show_license_info = open;
448 + }
449 + }
450 +
451 + /// Mask a 5-word key: show first word + ... + last word.
452 + fn mask_key(key: &str) -> String {
453 + let words: Vec<&str> = key.split('-').collect();
454 + if words.len() >= 2 {
455 + format!("{}-...-{}", words[0], words[words.len() - 1])
456 + } else {
457 + "***".to_string()
458 + }
459 + }
460 +
461 + /// Create a BrowserState, returning (Some(browser), None) on success or
462 + /// (None, Some(error)) on failure.
463 + fn init_browser(data_dir: &Path, shared: Arc<SharedState>) -> (Option<BrowserState>, Option<String>) {
464 + let sample_rate = 44100.0;
465 + match BrowserState::new(data_dir, shared, sample_rate) {
466 + Ok(mut browser) => {
467 + for arg in std::env::args().skip(1) {
468 + let path = PathBuf::from(&arg);
469 + if path.exists() {
470 + browser.import_path(&path);
284 471 }
285 472 }
473 + (Some(browser), None)
474 + }
475 + Err(e) => {
476 + tracing::error!("Failed to init browser: {e}");
477 + (None, Some(format!("{e}")))
286 478 }
287 479 }
288 480 }
@@ -298,6 +490,51 @@ impl eframe::App for AudioFilesApp {
298 490 }
299 491 }
300 492
493 + match self.screen {
494 + AppScreen::Activation => {
495 + self.draw_activation_screen(ctx);
496 + }
497 + AppScreen::Browser => {
498 + self.update_browser(ctx);
499 + }
500 + }
501 +
502 + // Show update notification overlay (bottom-right) — user must consent
503 + if self.update_checker.should_show() {
504 + let (version, notes, download_url) = {
505 + let s = self.update_checker.status.lock();
506 + (s.version.clone(), s.notes.clone(), s.download_url.clone())
507 + };
508 + egui::Area::new(egui::Id::new("update-banner"))
509 + .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0))
510 + .order(egui::Order::Foreground)
511 + .show(ctx, |ui| {
512 + egui::Frame::popup(ui.style())
513 + .inner_margin(12.0)
514 + .show(ui, |ui| {
515 + ui.set_max_width(280.0);
516 + ui.strong(format!("Update Available: v{}", version));
517 + if !notes.is_empty() {
518 + ui.label(&notes);
519 + }
520 + ui.add_space(4.0);
521 + ui.horizontal(|ui| {
522 + if ui.button("Download").clicked() && !download_url.is_empty() {
523 + let _ = open::that(&download_url);
524 + }
525 + if ui.button("Not Now").clicked() {
526 + self.update_checker.dismiss();
527 + }
528 + });
529 + });
530 + });
531 + }
532 + }
533 + }
534 +
535 + impl AudioFilesApp {
536 + /// All browser-mode update logic (tray, sync, drops, draw).
537 + fn update_browser(&mut self, ctx: &egui::Context) {
301 538 // Poll tray menu events
302 539 if let Some(ref tray) = self.tray {
303 540 if let Some(action) = tray.poll() {
@@ -436,35 +673,19 @@ impl eframe::App for AudioFilesApp {
436 673 });
437 674 }
438 675
439 - // Show update notification overlay (bottom-right) — user must consent
440 - if self.update_checker.should_show() {
441 - let (version, notes, download_url) = {
442 - let s = self.update_checker.status.lock();
443 - (s.version.clone(), s.notes.clone(), s.download_url.clone())
444 - };
445 - egui::Area::new(egui::Id::new("update-banner"))
446 - .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-12.0, -12.0))
447 - .order(egui::Order::Foreground)
448 - .show(ctx, |ui| {
449 - egui::Frame::popup(ui.style())
450 - .inner_margin(12.0)
451 - .show(ui, |ui| {
452 - ui.set_max_width(280.0);
453 - ui.strong(format!("Update Available: v{}", version));
454 - if !notes.is_empty() {
455 - ui.label(&notes);
456 - }
457 - ui.add_space(4.0);
458 - ui.horizontal(|ui| {
459 - if ui.button("Download").clicked() && !download_url.is_empty() {
460 - let _ = open::that(&download_url);
461 - }
462 - if ui.button("Not Now").clicked() {
463 - self.update_checker.dismiss();
464 - }
465 - });
466 - });
467 - });
676 + // License info button (top-right, before overlays)
677 + egui::Area::new(egui::Id::new("license-btn"))
678 + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
679 + .order(egui::Order::Foreground)
680 + .interactable(true)
681 + .show(ctx, |ui| {
682 + if ui.small_button("License").clicked() {
683 + self.show_license_info = !self.show_license_info;
684 + }
685 + });
686 +
687 + if self.show_license_info {
688 + self.draw_license_info(ctx);
468 689 }
469 690 }
470 691 }
@@ -0,0 +1,454 @@
1 + # audiofiles — Completed Work
2 +
3 + Archived completed sections from todo.md. All items below are done.
4 +
5 + ## Phase 0: Project Setup
6 +
7 + ### Done
8 + - [x] Init Cargo workspace (audiofiles-core, audiofiles-plugin, audiofiles-app, xtask)
9 + - [x] audiofiles-core: pure library crate, no async, no UI (rusqlite, sha2, thiserror)
10 + - [x] audiofiles-plugin: nih-plug CLAP/VST3 plugin crate with egui editor placeholder
11 + - [x] audiofiles-app: standalone binary (placeholder only)
12 + - [x] Set up SQLite with rusqlite + migrations (8 tables, 7 indexes, PRAGMA user_version tracking)
13 + - [x] xtask bundler for CLAP/VST3 packaging (`cargo xtask bundle audiofiles-plugin`)
14 + - [x] 4 tests: schema creation, migration versioning, idempotency, FK enforcement
15 + - [x] CI — `.build.yml` for builds.sr.ht (check, test, clippy, audit)
16 +
17 + ## Phase 1: Core Library — Sample Store & VFS
18 +
19 + ### Done
20 + - [x] CoreError type + Result alias + helpers (error.rs)
21 + - [x] Content-addressed store: import file → SHA-256 → copy to `{hash}.{ext}` (store.rs)
22 + - [x] Deduplication: skip copy when hash already exists
23 + - [x] Sample remove: delete file + row, CASCADE handles refs
24 + - [x] VFS CRUD: create/delete/rename VFS roots (vfs.rs)
25 + - [x] VFS node CRUD: create/delete/rename/move directories and sample links
26 + - [x] VFS node query: list children (dirs first), breadcrumb trail
27 + - [x] Root-level name conflict detection (SQLite NULL UNIQUE workaround)
28 + - [x] Tag CRUD: add/remove/query tags on samples (tags.rs)
29 + - [x] Tag query: find_by_tag, list_tag_names, list_tag_values
30 + - [x] 23 unit tests (4 db + 4 store + 10 vfs + 5 tags)
31 + - [x] Ratatui TUI app for interactive testing (app.rs, ui.rs, main.rs)
32 + - [x] TUI: vim-style nav, import files/dirs, mkdir, tag, rename, delete, cycle VFS
33 + - [x] Collections CRUD: create/delete collections, add/remove members (schema + 11 files implemented)
34 +
35 + ## Phase 2: CLAP Plugin Shell
36 +
37 + ### Done
38 + - [x] Plugin struct with SharedState bridge between audio + GUI threads
39 + - [x] BrowserState: DB, Store, VFS navigation ported from TUI app (state.rs)
40 + - [x] Audio preview: symphonia decode to interleaved stereo f32 (preview.rs)
41 + - [x] Preview playback: fill_output() with try_lock for audio thread safety
42 + - [x] egui editor: single-panel file browser (breadcrumb, file list, footer) (editor.rs)
43 + - [x] VFS selector dropdown (ComboBox to switch VFS roots)
44 + - [x] Breadcrumb navigation (clickable path segments)
45 + - [x] Keyboard navigation: Up/Down/J/K, Enter/Right enter dir, Backspace/Left go up, Space toggle preview
46 + - [x] Play buttons per sample row + double-click to preview
47 + - [x] Copy-path-to-clipboard button (ctx.copy_text)
48 + - [x] Plugin state persistence: db-path persisted via nih-plug #[persist]
49 + - [x] Default data dir via `dirs` crate (platform data_dir/audiofiles)
50 + - [x] CLAP + VST3 bundle builds successfully
51 +
52 + ### Done (UX Overhaul)
53 + - [x] Three-panel layout (sidebar + columnar file list + detail panel)
54 + - [x] Sortable columns: BPM, key, duration, classification, name
55 + - [x] Detail panel with waveform + metadata + tag management
56 + - [x] Tag editor in detail panel (add/remove tags on selected sample)
57 + - [x] Search bar with folder/global scope toggle
58 + - [x] Dark theme applied via egui Visuals
59 +
60 + ## Phase 3: Import Pipeline
61 +
62 + Store is always flat (content-addressed blobs by SHA-256 hash). VFS controls how samples appear to the user.
63 +
64 + ### Done
65 + - [x] Folder import: background worker walks directory, hashes + copies + creates VFS nodes (import.rs)
66 + - [x] Progress reporting: pre-walk file count → progress bar, per-file updates, cancel support
67 + - [x] Import dialog in plugin UI: "Import Folder" button in breadcrumb bar, rfd folder picker, progress screen
68 + - [x] Drag-and-drop import: directories use background worker, single files remain synchronous
69 + - [x] Analysis flow wired into import: auto-transitions to ConfigureAnalysis after import completes
70 +
71 + ### Done (Phase 3 completion)
72 + - [x] Import options UI: flat / new VFS / merge into existing VFS — ConfigureImport screen with radio buttons, VFS name editor, merge VFS picker (editor.rs, state.rs)
73 + - [x] Post-import tag prompt: TagFolders screen with per-folder tag input, real-time validation, Apply/Skip buttons (editor.rs, state.rs)
74 + - [x] Duplicate detection feedback: ImportFileResult enum, duplicates counter through worker, status shows "X duplicates skipped" (import.rs, state.rs)
75 + - [x] ImportStrategy enum (Flat/NewVfs/MergeIntoVfs), ImportedFolder struct, import_directory_flat(), import_structured() (import.rs)
76 + - [x] Drag-and-drop directories use MergeIntoVfs strategy (main.rs), "Import Folder" button shows config screen
77 + - [x] 84 tests pass, full workspace compiles cleanly
78 +
79 + ## Phase 4: Audio Analysis
80 +
81 + ### Done
82 + - [x] Analysis module structure (analysis/ with 10 submodules)
83 + - [x] Symphonia → mono f32 decode for analysis (analysis/decode.rs)
84 + - [x] Basic loudness: peak_db, rms_db (analysis/basic.rs, 4 tests)
85 + - [x] LUFS loudness via bs1770 ITU-R BS.1770-4 (analysis/loudness.rs, 2 tests)
86 + - [x] Spectral features via realfft STFT: centroid, flatness, rolloff, ZCR, onset strength (analysis/spectral.rs, 4 tests)
87 + - [x] BPM + key detection via stratum-dsp analyze_audio (analysis/bpm.rs, 2 tests)
88 + - [x] Rule-based classification: 12 classes (Kick, Snare, HiHat, Cymbal, Percussion, Bass, Vocal, Synth, Pad, Fx, Noise, Music) (analysis/classify.rs, 6 tests)
89 + - [x] Loop detection: cross-correlation + beat alignment heuristic (analysis/loop_detect.rs, 4 tests)
90 + - [x] Tag suggestion engine: AnalysisResult → Vec<TagSuggestion> with confidence + reason (analysis/suggest.rs, 7 tests)
91 + - [x] AnalysisConfig: 7 toggleable analyses, all default true (analysis/config.rs)
92 + - [x] Background worker thread: mpsc channels, progress reporting, cancellation (analysis/worker.rs)
93 + - [x] analyze_sample() orchestrator + save_analysis() DB writer (analysis/mod.rs)
94 + - [x] DB migration 003: added lufs, spectral_flatness, spectral_rolloff, zero_crossing_rate, classification columns
95 + - [x] Plugin state: ImportMode enum (None/ConfigureAnalysis/Analyzing/ReviewSuggestions), ReviewItem, SuggestionState
96 + - [x] Plugin state: start_analysis_flow, run_analysis, poll_analysis, cancel_analysis, apply_accepted_suggestions
97 + - [x] Plugin UI: configure analysis screen (checkboxes per analysis type)
98 + - [x] Plugin UI: analysis progress screen (progress bar, current file, cancel)
99 + - [x] Plugin UI: review/audit screen (two-panel: sample list + tag suggestions with checkboxes, accept/reject all, apply)
100 + - [x] CLAP + VST3 bundles build
101 +
102 + ### Done (post-Phase 4)
103 + - [x] Wire analysis flow into import pipeline (auto-transition in poll_import Complete handler)
104 +
105 + ### Done (UX Overhaul)
106 + - [x] Waveform data generation: downsampled peak pairs stored as f32 blob in waveform_data table (DB migration 004)
107 + - [x] Waveform rendering in egui detail panel (custom Painter-based widget with click-to-seek)
108 +
109 + ## Phase 5: Search & Filtering
110 +
111 + ### Done (UX Overhaul)
112 + - [x] Multi-dimensional filter query builder: BPM range, key, duration, classification, tags (search.rs)
113 + - [x] Full-text search across sample name (search bar in toolbar, folder/global scope)
114 + - [x] Filter panel UI: BPM range, duration range, 12 classification checkboxes, key selector, tag pills (ui/filter_panel.rs)
115 + - [x] Sort by analysis column: Name, BPM, Key, Duration, Classification (click headers, ascending/descending toggle)
116 + - [x] Multi-select: Cmd+Click toggle, Shift+Click range, Cmd+A select all (Selection struct in state.rs)
117 + - [x] Context menus: right-click sample (Preview, Copy Path, Delete), right-click folder (Open, Delete)
118 + - [x] Enriched file list query: LEFT JOIN vfs_nodes + audio_analysis (list_children_enriched in vfs.rs)
119 +
120 + ### Done (Phase 5 completion)
121 + - [x] Key compatibility filter (compatible_keys() in search.rs, KeyFilterMode::Compatible, filter_panel toggle)
122 + - [x] Smart folders: saved filter queries, stored as JSON in DB (smart_folders.rs, smart_folders table)
123 + - [x] Smart folder sidebar section in plugin UI (sidebar.rs, collapsible section)
124 + - [x] Similarity search: feature vector distance (weighted Euclidean on analysis columns, similarity.rs)
125 + - [x] "Find similar" context menu action on any sample (file_list.rs, state.find_similar())
126 + - [x] Near-duplicate detection: peak envelope fingerprint comparison (fingerprint.rs, DB migration 006, "Find Duplicates" context menu)
127 +
128 + ## Phase 6: Bulk Operations & Polish
129 +
130 + ### Done
131 + - [x] Keyboard shortcuts (j/k navigate, space preview, enter open, / search, Cmd+A select all, Shift+Arrow range select)
132 + - [x] Right-click context menus throughout
133 + - [x] DB migration 005: user_config table, transaction() helper on Database
134 + - [x] Rename pattern engine: {name}, {ext}, {bpm}, {key}, {class}, {duration}, {n}/{nn}/{nnn} tokens, separator collapsing, dedup (rename.rs)
135 + - [x] Bulk core functions: collect_subtree, list_all_directories (recursive CTEs), restore_node, bulk_add_tag, bulk_remove_tag
136 + - [x] Undo system: UndoOp enum (BulkDelete/BulkMove/BulkRename/BulkTagAdd/BulkTagRemove), 50-deep stack, Cmd+Z shortcut, toolbar undo button
137 + - [x] Bulk tag modal: add/remove tag to multi-selection, validation, sample name list (Cmd+T shortcut)
138 + - [x] Bulk move modal: scrollable directory picker with full paths, move to root or subfolder
139 + - [x] Bulk rename modal: pattern input with token chips, live preview table (old→new), error display (F2 shortcut)
140 + - [x] Multi-select context menu: Tag.../Move to.../Rename.../Copy Paths/Delete (right-click when >1 selected)
141 + - [x] Bulk delete with confirmation: DeleteMultiple variant, snapshot subtrees+tags for undo
142 + - [x] Column customization: ColumnConfig with show/hide for Classification, BPM, Key, Duration, Peak dB, Tags; persisted to user_config table
143 + - [x] Merged icon into Name column, eliminated empty vertical gutters in file list
144 + - [x] 21 new tests (10 rename, 5 vfs bulk, 4 tags bulk, 2 db transaction) → 109 total
145 +
146 + ## Phase 7: Export
147 +
148 + Raw filesystem export + Rhai plugin-based device export (community/device-maker extensible profiles).
149 +
150 + Requires: Phase 4 format detection (sample_rate, channels in audio_analysis table)
151 +
152 + ### Done — Raw Export
153 + - [x] Export VFS subtree to real filesystem (preserving directory structure)
154 + - [x] Export flattened with naming pattern
155 + - [x] Export with metadata sidecar (.audiofiles.json per sample)
156 + - [x] Hardlink optimization for same-filesystem export (transparent fallback to copy)
157 + - [x] Export dialog in plugin UI (destination, format, channels, structure, sidecar, progress)
158 + - [x] Single-item Export... in context menus (sample + directory)
159 +
160 + ### Done — Core Export Engine (`audiofiles-core/src/export/`)
161 + - [x] DeviceProfile, AudioConstraints, NamingRules, ExportConstraints types (export/profile.rs)
162 + - [x] ChannelConstraint, NamingCase enums (export/profile.rs)
163 + - [x] DeviceProfileSummary for UI listing (export/profile.rs)
164 + - [x] ExportFormat::Aiff variant + AIFF dispatch in export_single_item (export/mod.rs)
165 + - [x] AIFF encoder: big-endian PCM, 80-bit extended sample rate, 16/24-bit (export/encode_aiff.rs)
166 + - [x] Filename sanitizer: case, separator, stripping, truncation (export/sanitize.rs)
167 + - [x] device_profile field on ExportConfig (export/mod.rs)
168 + - [x] AIFF radio button in export UI (ui/export_screens.rs)
169 +
170 + ### Done — Rhai Plugin Runtime (`crates/audiofiles-rhai/`)
171 + - [x] audiofiles-rhai crate (rhai sync feature, toml, serde, thiserror, audiofiles-core)
172 + - [x] PluginError enum: ManifestParse, ManifestMissing, ManifestInvalid, ScriptCompile, ScriptRuntime, Io (error.rs)
173 + - [x] Sandboxed Rhai engine: 100K ops, 32-level calls, 10K string, no print/debug (engine.rs)
174 + - [x] TOML manifest parsing → DeviceProfile conversion (manifest.rs)
175 + - [x] RhaiSampleInfo + RhaiExportContext wrapper types with exported getter modules (types.rs)
176 + - [x] Host API: pad, truncate, case, replace, strip, format_index, file_stem/ext (host_api.rs)
177 + - [x] CompiledHooks + hook runners: validate_sample, transform_filename, pre/post_export (hooks.rs)
178 + - [x] Filesystem plugin discovery: scan dirs for manifest.toml (loader.rs)
179 + - [x] PluginRegistry: case-insensitive lookup, user-overrides-bundled, sorted listing (registry.rs)
180 + - [x] Bundled plugin embedding via include_str! (bundled.rs)
181 + - [x] create_registry() public API: bundled + user plugins from ~/.config/audiofiles/plugins/user/ (lib.rs)
182 + - [x] Backend trait: list_device_profiles() method
183 + - [x] DirectBackend: PluginRegistry behind device-profiles feature flag
184 + - [x] IPC: device_profiles.list method constant
185 +
186 + ### Done — Bundled Device Plugins (`crates/audiofiles-rhai/plugins/bundled/`)
187 + Each plugin is a `manifest.toml`. All declarative, no Rhai scripts needed.
188 + - [x] Dirtywave M8 (m8/) — 44.1k, 16/24-bit, mono, 127-char lower
189 + - [x] Elektron Digitakt (digitakt/) — 48k, 16-bit, mono, 64-char lower
190 + - [x] Elektron Digitakt II (digitakt_ii/) — 48k, 16-bit, both, 64-char lower
191 + - [x] Roland SP-404 MKII (sp404mkii/) — 44.1/48k, 16/24-bit, both, 12-char upper, 128MB limit
192 + - [x] Akai MPC One/Live (mpc/) — 44.1/48k, 16/24-bit, both, 64-char lower
193 + - [x] Polyend Tracker (polyend_tracker/) — 44.1k, 16-bit, both, 8-char upper
194 + - [x] Synthstrom Deluge (deluge/) — 44.1k, 16/24-bit, both, WAV/AIFF, 64-char original
195 + - [x] 1010music Blackbox (blackbox/) — 48k, 16/24-bit, both, 64-char lower
196 + - [x] Korg Volca Sample 2 (volca_sample_2/) — 31.25k, 16-bit, mono, 100-slot limit
197 + - [x] TE OP-1 / OP-1 Field (op1/) — 44.1k, 16-bit, mono, AIFF, 11-char lower
198 + - [x] Elektron Octatrack (octatrack/) — 44.1k, 16/24-bit, both, WAV/AIFF, 64-char upper
199 + - [x] Elektron Model:Samples (model_samples/) — 48k, 16-bit, mono, 64-char lower
200 + - [x] Novation Circuit Rhythm (circuit_rhythm/) — 44.1/48k, 16-bit, both, 32-char lower
201 + - [x] NI Maschine+ (maschine_plus/) — 44.1/48k, 16/24-bit, both, 128-char original
202 +
203 + ### Done — Pipeline Integration
204 + - [x] run_export() applies device profile constraints (format, rate, depth, channels from profile)
205 + - [x] run_export() applies NamingRules via sanitize_filename() when device profile set
206 + - [x] run_export() runs validate_sample hook (reject incompatible samples)
207 + - [x] run_export() runs transform_filename hook (custom naming beyond NamingRules)
208 + - [x] File size limit enforcement (skip/warn if encoded file exceeds max_file_size_bytes)
209 + - [x] Dedup with device-safe `_2`, `_3` suffixes when sanitization creates collisions
210 + - [x] name_overrides field on ExportConfig for hook-transformed names
211 + - [x] resolve_output_names made pub for backend pre-computation
212 +
213 + ### Done — UI
214 + - [x] Device export dialog in plugin UI (profile picker, destination, preview, progress)
215 + - [x] Device profile picker: ComboBox listing bundled + user profiles with manufacturer metadata
216 + - [x] Custom profile option ("None (manual)" hides profile overrides, shows all manual controls)
217 +
218 + ## Phase 8: Standalone App (Done sections)
219 +
220 + Wraps audiofiles-core in a native window, for users who want to manage outside DAW.
221 +
222 + ### Done
223 + - [x] Created audiofiles-browser shared crate (editor, state, preview extracted from plugin)
224 + - [x] Rewired audiofiles-plugin to use audiofiles-browser (editor.rs, state.rs deleted from plugin)
225 + - [x] audiofiles-app binary: eframe + cpal standalone reusing plugin UI panels
226 + - [x] cpal audio output stream for preview playback (audio.rs)
227 + - [x] Native file drag-and-drop import (egui dropped_files)
228 + - [x] CLI file argument import (open files from command line / OS file associations)
229 + - [x] macOS Info.plist with audio file type associations (.wav, .flac, .mp3, .ogg, .aiff)
230 + - [x] Import logic ported from TUI to BrowserState (import_path, import_single_file, import_directory_recursive)
231 + - [x] Old TUI app replaced (ratatui/crossterm removed)
232 + - [x] 84 tests pass, full workspace compiles cleanly
233 +
234 + ### Done (UX Overhaul)
235 + - [x] Editor.rs refactored from 1006→191 lines (thin dispatcher to 10 UI submodules)
236 + - [x] 14 new UI files: ui/{mod,theme,widgets,toolbar,sidebar,file_list,detail,footer,filter_panel,import_screens,overlays}.rs + waveform.rs
237 + - [x] 109 tests pass (9 UX overhaul + 21 Phase 6 bulk ops)
238 +
239 + ## Pre-Launch Fixes (from audit 2026-02-27)
240 +
241 + ### Done
242 + - [x] `CoreError::RenameInvalid` variant, `RenamePattern::parse()` returns typed error (rename.rs, error.rs)
243 + - [x] `import_single_file()` returns `Result<_, CoreError>` instead of String (browser/import.rs)
244 + - [x] `bulk_add_tag`/`bulk_remove_tag` wrapped in explicit transactions (tags.rs)
245 +
246 + ### Done
247 + - [x] Add tests for `state.rs` orchestration logic (95 tests added: selection, bulk ops, import/analysis, navigation, rename, column config, misc)
248 + - [x] `audio.rs` `start_output_stream()` returns typed `AudioError` enum
249 +
250 + ## Audit Follow-Up (2026-02-28)
251 +
252 + ### Done
253 + - [x] Fix 4 clippy warnings in audiofiles-browser: `drop_non_drop`, `clone_on_copy`, two `collapsible_if`
254 + - [x] Escape SQL LIKE wildcards in search.rs and tags.rs via `escape_like()`
255 + - [x] Wrap each migration step in transactions
256 + - [x] Remove unused import in error.rs test module
257 + - [x] Validate hash parameter in `store.rs::sample_path()` (validate_hash checks 64-char lowercase hex)
258 + - [x] Add tests for `audiofiles-plugin` (8 tests) and `audiofiles-app` (5 tests)
259 + - [x] `contents.clone()` in file_list.rs confirmed as Arc clone (cheap reference count bump)
260 +
261 + ## Audit Follow-Up (2026-02-27)
262 +
263 + ### Done
264 + - [x] Add tests for `browser/preview.rs` (7 tests: mono/stereo/multichannel decode, sample rate, error cases)
265 + - [x] Split `state.rs` into directory module (state/mod.rs, navigation.rs, import_workflow.rs, bulk_ops.rs, tests.rs)
266 +
267 + ## Audit Follow-Up (2026-02-28, third audit)
268 +
269 + ### Done — Clippy
270 + - [x] Fix 3 `items_after_test_module`: moved `save_analysis`, `decode_to_mono`, `worker_loop` above their test modules
271 + - [x] Fix 2 `io_other_error` in test code: use `std::io::Error::other()`
272 + - [x] Fix `identity_op` in test: changed `0 + 1` to `1`
273 + - [x] `too_many_arguments`: added `#[allow(clippy::too_many_arguments)]` on test helper
274 +
275 + ## Audit Follow-Up (2026-03-01, test coverage + error handling)
276 +
277 + ### Done — Test Coverage
278 + - [x] Centralized `split_name_ext` from 3 duplicate locations into `util.rs` + 3 tests (normal, no ext, multiple dots)
279 + - [x] Smart folder error tests: 2 tests (rename nonexistent, corrupt JSON graceful fallback)
280 +
281 + ### Done — Error Handling
282 + - [x] Added `CoreError::Serialization(String)` variant — distinct from `Export` for JSON serialization failures
283 + - [x] Fixed smart_folders.rs: `CoreError::Export` → `CoreError::Serialization` for serde_json errors
284 + - [x] Added `eprintln!` warning for corrupt smart folder JSON (was silent `unwrap_or_default`)
285 + - [x] Error handling audit: no other actionable issues (eprintln is correct for CLAP plugin crate, bundled.rs already returns Result)
286 +
287 + ## Phase 7B: MIDI Instrument Mode (2026-03-01)
288 +
289 + ### Done — Core Types (audiofiles-core/src/instrument.rs)
290 + - [x] InstrumentMode enum (Chromatic, MultiSample)
291 + - [x] AdsrEnvelope struct (5ms attack, 100ms decay, sustain 1.0, 50ms release defaults)
292 + - [x] VelocityTarget enum (Volume)
293 + - [x] KeyZone struct (sample_hash, name, root/low/high note, velocity range)
294 + - [x] InstrumentConfig struct (mode, envelope, velocity_target, max_voices=8)
295 + - [x] key_to_root_note(): parse "A minor" → MIDI note 57, all 12 pitch classes + sharps/flats
296 + - [x] note_name(): MIDI note → "C3", "A#4" (C-1=0 convention, C3=48, C4=60)
297 + - [x] 9 tests
298 +
299 + ### Done — Instrument Playback State (audiofiles-browser/src/instrument.rs)
300 + - [x] EnvelopePhase enum (Idle, Attack, Decay, Sustain, Release)
301 + - [x] Voice struct (active, note, velocity, age, fractional position, zone_index, envelope state)
302 + - [x] LoadedZone struct (PreviewBuffer, root/low/high note, velocity range)
303 + - [x] InstrumentPlayback struct (config, zone_buffers, fixed voice pool, note_counter, active, sample_rate)
304 + - [x] Extended SharedState with instrument: Mutex<InstrumentPlayback>
305 + - [x] Extended BrowserState with instrument_visible, instrument_root_note
306 + - [x] 2 tests
307 +
308 + ### Done — MIDI Voice Engine (audiofiles-plugin/src/instrument.rs)
309 + - [x] fill_instrument(): try_lock, drain MIDI events, render voices, return bool
310 + - [x] NoteOn: zone matching, free voice allocation or oldest-note stealing
311 + - [x] NoteOff: move matching voices to Release phase
312 + - [x] Choke: immediately deactivate matching voices
313 + - [x] Pitch shifting: linear interpolation, rate = 2^(semitone/12) * (source_rate/host_rate)
314 + - [x] ADSR envelope: per-sample advance, linear ramps for A/D/R, constant S
315 + - [x] Velocity: gain = envelope_level * velocity (Volume target)
316 + - [x] Plugin integration: MIDI_INPUT = MidiConfig::Basic, instrument priority over preview
317 + - [x] 8 tests
318 +
319 + ### Done — Sample Loading
320 + - [x] load_chromatic_sample(): decode, derive root from analysis, single zone 0-127
321 + - [x] toggle_instrument(): toggle active/visible
322 + - [x] add_instrument_zone(): decode, append zone, set MultiSample mode
323 + - [x] remove_instrument_zone(): remove zone, kill affected voices, fix indices
324 + - [x] Context menu "Play as Instrument" for samples
325 +
326 + ### Done — Keyboard + Panel UI
327 + - [x] I key toggles instrument panel visibility
328 + - [x] Instrument panel: bottom panel (120px), 3 sections
329 + - [x] Mode selector: Chromatic / Multi-sample radio buttons
330 + - [x] Piano keyboard: 3-octave custom painter, active note highlighting, root note dot
331 + - [x] Click-to-set root note on keyboard, octave +/- navigation
332 + - [x] ADSR sliders: logarithmic for time params, A/D 1ms-5s, S 0-1, R 1ms-10s
333 + - [x] Zone bars: colored horizontal bars below keyboard per zone (multi-sample mode)
334 + - [x] Right-click zone to remove
335 + - [x] Drag-and-drop: file list samples → keyboard creates zone (root +/- 6 semitones)
336 +
337 + ### Files Created
338 + - crates/audiofiles-core/src/instrument.rs
339 + - crates/audiofiles-browser/src/instrument.rs
340 + - crates/audiofiles-plugin/src/instrument.rs
341 + - crates/audiofiles-browser/src/ui/instrument_panel.rs
342 +
343 + ### Files Modified
344 + - crates/audiofiles-core/src/lib.rs
345 + - crates/audiofiles-browser/src/lib.rs
346 + - crates/audiofiles-browser/src/state/mod.rs
347 + - crates/audiofiles-browser/src/editor.rs
348 + - crates/audiofiles-browser/src/ui/mod.rs
349 + - crates/audiofiles-browser/src/ui/file_list.rs
350 + - crates/audiofiles-plugin/src/lib.rs
351 +
352 + ### Test Count
353 + - 410 → 429 (19 new: 9 core, 2 browser, 8 plugin)
354 +
355 + ## Phase 9: Cloud Sync (MNW SyncKit)
356 +
357 + ### Done
358 + - [x] Create `crates/audiofiles-sync/` crate (lib.rs, error.rs, service.rs, auth.rs, scheduler.rs)
359 + - [x] Workspace deps: tokio, uuid, base64, chrono, rand; synckit-client path dep
360 + - [x] Migration 007: `sync_state` table, `sync_changelog` table, `vfs.sync_files` column
361 + - [x] 27 triggers across 9 tables (samples, audio_analysis, vfs, vfs_nodes, tags, collections, collection_members, smart_folders, user_config), all guarded by `applying_remote != '1'`
362 + - [x] `service.rs`: table_columns whitelist, FK-safe UPSERT/DELETE ordering, push/pull with `spawn_blocking` (Connection is !Send), composite PK handling, initial snapshot, cleanup
363 + - [x] `auth.rs`: PKCE helpers (verifier/challenge/state), localhost callback server
364 + - [x] `scheduler.rs`: 60s check interval, exponential backoff (2^n min, max 15), auto-sync guards
365 + - [x] `lib.rs`: `SyncManager` public API, `SyncStatus`/`SyncState` types, interior-mutable command channel
366 + - [x] `audiofiles-app/main.rs`: tokio runtime (2 threads), `SyncManager` from env vars (`AF_SYNC_SERVER_URL`, `AF_SYNC_API_KEY`), session restore, scheduler start, `needs_refresh` polling
367 + - [x] `Vfs` struct: `sync_files: bool` field, `set_vfs_sync_files`/`get_vfs_sync_files` in core + Backend trait + DirectBackend
368 + - [x] `sync_panel.rs`: egui Window overlay with 4 states (Disconnected, Authenticating, NeedsEncryption, Ready)
369 + - [x] Ready state: Sync Now, auto-sync toggle, interval selector (5/15/30/60 min), per-VFS "Sync audio files" checkboxes, Disconnect
370 + - [x] Toolbar "Sync" button, Escape dismisses panel
371 + - [x] CLAP plugin updated: passes `None` for sync manager
372 + - [x] 483 tests passing, 0 clippy warnings, DB v7
373 +
374 + ### Alpha Polish (2026-03-08)
375 + - [x] VFS management UI: context menus (rename/delete) on sidebar libraries, "+" button for new library
376 + - [x] Directory management: "New Folder" and "Rename" in directory context menu
377 + - [x] 4 modal dialogs: VFS create, VFS rename, directory create, directory rename (overlays.rs, editor.rs)
378 +
379 + ### Done — Per-VFS sync_files toggle
380 + - [x] Per-VFS sync_files toggle: wired into blob upload/download queries (sync_files=1 WHERE clause)
381 +
382 + ### Testing — Export Pipeline (Done)
383 + - [x] `resolve_output_names()` tests — 9 tests (no pattern, format changes, patterns, name overrides, dedup after sanitization)
384 + - [x] `run_export()` tests — 5 tests (AIFF format, cancel callback, missing source error, sidecar fields, directory structure)
385 +
386 + ### 9F: Sync Without Blobs (Done)
387 + - [x] Post-pull cloud_only marking: `mark_cloud_only_samples()` scans for samples with no local blob, sets cloud_only=1 with trigger suppression
388 + - [x] VFS nodes referencing cloud-only samples show cloud icon, muted text, no preview/playback/export/instrument
389 + - [x] Analysis results sync independently (audio_analysis in sync whitelist, usable for search/filter without local blob)
390 + - [x] Tags and collections work on cloud-only samples (metadata-only operations)
391 + - [x] `cloud_only` field added to `VfsNodeWithAnalysis`, enriched queries JOIN samples table
392 + - [x] 7 new tests (4 mark_cloud_only, 3 enriched query cloud_only)
393 +
394 + ## Audit Action Items (2026-03-11, sixth audit) — All resolved
395 +
396 + - [x] CRITICAL: Clear applying_remote flag on startup (crash recovery -- silent data loss vector) — cleared at top of perform_sync()
397 + - [x] Migrate browser crate from eprintln! to tracing (25 sites across 8 files, appropriate error/warn/debug levels)
398 + - [x] Split import_screens.rs (668 LOC) into directory module (configure.rs, progress.rs, tagging.rs)
399 + - [x] Fix theme include_str! paths (moved 9 theme TOMLs into audiofiles-browser/themes/, paths now 2 levels)
400 + - [x] Add end-to-end integration test (3 tests: full pipeline import->analyze->search->tag->export, analysis roundtrip verification, multi-sample search with combined filters)
401 +
402 + ## Audit Action Items (2026-03-13, seventh audit — pre-launch skeptical lens)
403 +
404 + - [x] Use SmallVec or pre-allocated buffer for MIDI events in audio path (SmallVec<[NoteEvent; 8]> replaces Vec, zero heap allocation for ≤8 events per buffer)
405 + - [x] Add allowlist validation to `query_sample_field` SQL column interpolation (ALLOWED_FIELDS const, returns CoreError::Internal on disallowed field)
406 + - [x] Add hash re-verification method on content store (`SampleStore::verify_sample` — re-hashes file, compares to expected hash, 3 tests)
407 + - [x] Fix test count: 532 tests (was 528, +4 new: allowlist validation, verify match/corruption/missing)
408 +
409 + ## Brand Rename (2026-03-19)
410 +
411 + ### Done
412 + - [x] Rename "AudioFiles" → "audiofiles" in all user-visible strings (window title, tray tooltip, headings, sync panel, auth callback)
413 + - [x] Rename data directory path `.join("AudioFiles")` → `.join("audiofiles")`
414 + - [x] Rename export default directory "AudioFiles Export" → "audiofiles Export"
415 + - [x] Rename CFBundleName in Info.plist
416 + - [x] Rename .app/.zip in release.sh (`AudioFiles.app` → `audiofiles.app`)
417 + - [x] Rename in all doc comments, tracing messages across 6 crates
418 + - [x] Rename in all project docs (CLAUDE.md, launch.md, audit.md, todo.md, content_seed.md, architecture.md, pitches, strategy docs)
419 + - [x] Rust struct `AudioFilesApp` unchanged (PascalCase convention)
420 + - [x] Crate names `audiofiles-*` unchanged (already lowercase)
421 + - [x] Bundle identifier `com.audiofiles.app` unchanged (already lowercase)
422 + - [x] 560 tests pass, cargo check + clippy clean, zero stale references
423 +
424 + ## Deferred (Done)
425 +
426 + - [x] Resolve PreviewBuffer name collision (ipc/protocol.rs renamed to IpcPreviewBuffer)
427 +
428 + ---
429 +
430 + ## Rust Patterns Audit (2026-03-21)
431 +
432 + - [x] Remove unnecessary `codec_params.clone()` in 3 decode paths
433 + - [x] Stop cloning full UpdateStatus struct every UI frame — extract 3 fields under lock
434 + - [x] Deferred breadcrumb mutation — iterate by ref, apply navigation after loop
435 + - [x] Improve HashMap deduplicate in rename.rs — use get_mut to avoid extra clones
436 + - [x] Eliminate double hashes clone in bulk_ops.rs — move into undo op instead of clone
437 + - [x] Reduce import_workflow hash clones — build SampleHash once, reuse for name fallback
438 + - [x] Tag tree sidebar — dot-separated tags rendered as collapsible folder tree (sidebar.rs)
439 + - [x] Remove Linux Wayland drag-out backend (linux.rs deleted, wayland-client/raw-window-handle deps removed)
440 + - [x] Remove unused smallvec dependency
441 + - [x] Fix `!is_ok()` → `is_err()` in vfs_mirror test
442 +
443 + ---
444 +
445 + ## TagTree Integration (2026-03-21)
446 +
447 + - [x] Add `tagtree` workspace dependency
448 + - [x] Replace local `validate_tag()` with `tagtree::validate_with()` (TagConfig: max_depth 5, max_length 100)
449 + - [x] Replace local `escape_like()` with `tagtree::escape_like()` in tags.rs and search.rs
450 + - [x] Replace `find_by_prefix` LIKE pattern with `tagtree::like_descendant_pattern()`
451 + - [x] Replace `remove_tags_by_prefix` LIKE pattern with `tagtree::like_descendant_pattern()`
452 + - [x] Replace `list_children_tags` with `tagtree::children_at_prefix()`
453 + - [x] Remove local `escape_like()` from util.rs (and its 5 tests)
454 + - [x] All 568 tests pass
@@ -0,0 +1,200 @@
1 + # audiofiles -- Audit History
2 +
3 + Full chronological audit log. See [audit_review.md](./audit_review.md) for current state.
4 +
5 + ## Changes Since Last Audit
6 +
7 + ### Sixteenth audit (2026-03-28, Run 12 cross-project)
8 + - **Test count:** 611. 0 clippy warnings. 0 failures.
9 + - **Grade:** A (maintained). v0.3.0.
10 + - **No code changes since ML classifier (2026-03-26).**
11 + - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
12 + - **Mandatory surprise:** None. Previous surprises (sync/service.rs zero production unwraps, applying_remote crash recovery) all resolved.
13 + - **No new code findings.** All previous items remain resolved.
14 +
15 + ### Two-Layer ML Classifier (2026-03-26)
16 + - **Test count:** 585 -> 610 (+25). 0 clippy warnings.
17 + - **DB version:** v10 -> v11 (migration 011: `classification_confidence REAL` column on `audio_analysis`, sync triggers updated).
18 + - **New module:** `analysis/mfcc.rs` — MFCC feature extraction from existing STFT magnitudes. 26-band mel filterbank, DCT-II, 13 coefficients aggregated as mean+variance. 7 unit tests.
19 + - **Rewritten:** `analysis/classify.rs` — Two-layer ML classifier replacing rule-based `classify_full()`. Layer 1: rule-based broad classifier (Drum/Bass/Vocal/Synth/Pad/Fx/Noise/Music/Ambience/Impact/Foley/Texture). Layer 2: 200-tree Random Forest for drum sub-classification. New types: `BroadClass`, `ClassificationResult`, `TreeNode`, `RandomForestModel`. 35-feature vector (9 scalar + 26 MFCC). Model embedded via `include_bytes!` (7.4MB), lazy init via `OnceLock`. 5 new tests.
20 + - **New crate:** `audiofiles-train` — RF training binary (excluded from default-members). Custom implementation (no linfa dependency). Stratified 5-fold CV, rayon parallel training, JSON serialization.
21 + - **Modified:** `analysis/spectral.rs` — Added `compute_spectral_features_with_frames()` returning magnitude vectors alongside features. Original function delegates and discards frames.
22 + - **Modified:** `analysis/mod.rs` — Pipeline now computes MFCCs from STFT frames, builds extended ClassifyInput, calls `classify_ml()`. `AnalysisResult` gains `classification_confidence: Option<f64>`.
23 + - **Modified:** `analysis/suggest.rs` — Confidence-gated tag suggestions (only suggest if ML confidence >= 0.5). Reason string includes ML confidence percentage.
24 + - **Modified:** `classify_validation.rs` — Reports per-class confidence stats (mean/median/P10) and precision/recall/F1.
25 + - **Accuracy:** 94.4% strict / 95.6% lenient on 4343 labeled drum samples (was 36%/61% rule-based).
26 + - **Model file:** `crates/audiofiles-core/models/layer2_drum.json` (7.4MB, 200 trees, max_depth=25).
27 + - **No grade change expected** — all new code follows existing patterns, clippy clean, comprehensive tests.
28 +
29 + ### Fourteenth audit (2026-03-22, app test coverage)
30 + - **Test count:** 560 -> 585 (+25). 0 clippy warnings.
31 + - **Grade:** A (maintained). audiofiles-app Test B -> A-.
32 + - **updater.rs:** 8 new tests — UpdateStatus default, dismiss/should_show state machine (4 states), UpdateResponse deserialization, CURRENT_VERSION semver validation.
33 + - **main.rs:** 7 new tests — load_api_key (from file, trims whitespace, empty/whitespace-only/missing file), save_api_key, save-and-load roundtrip.
34 + - **tray.rs:** 2 new tests — build_icon produces valid 18x18 RGBA, icon buffer dimensions and pixel content verification. `build_icon` changed from `fn` to `pub(crate) fn` for testability.
35 + - **Still open (audiofiles-app):** eframe UI update loop, start_output_stream, AppTray::new/poll — all platform-dependent, inherently hard to unit test.
36 +
37 + ### Thirteenth audit (2026-03-19, VP-tree addition)
38 + - **Test count:** 560 (+25). 0 clippy warnings.
39 + - **Grade:** A (maintained). Performance A- -> A. v0.3.0.
40 + - **VP-tree module:** New `vp_tree.rs` — generic `VpTree<T>` with `build()`, `find_nearest()`, `find_within()`. 10 tests including brute-force correctness check.
41 + - **FingerprintIndex:** Two-phase near-duplicate search. Phase 1: VP-tree on 16-bin compact Euclidean features (proper metric, O(log n)). Phase 2: full NCC verification on candidates. Build < 1s for 100K. Query ~3ms at 100K (was 2.7s linear scan — ~900x speedup). 4 tests.
42 + - **SimilarityIndex:** VP-tree on fixed-normalization Euclidean features. Ranges computed once from dataset (not per-query). Minor semantic change: single new sample no longer shifts normalization of all others. Sub-ms queries at 100K (was 26ms linear scan). 5 tests.
43 + - **DirectBackend integration:** Both indexes cached as `Mutex<Option<...>>`, lazy build on first query, auto-invalidation on `save_analysis`.
44 +
45 + ### Twelfth audit (2026-03-19, AF-focused)
46 + - **Test count:** 535. 0 clippy warnings.
47 + - **Grade:** A- -> A. v0.3.0.
48 + - **Major additions:** Native macOS drag-out (drag_out/ module: mod.rs, macos.rs, windows.rs), brand refresh (audiofiles theme, af/ logo in Recursive font, app icon with squircle), description.md rewrite.
49 + - **Crate removal:** audiofiles-plugin, audiofiles-ipc, and xtask removed (standalone-only). 7 crates -> 5. nih-plug workspace deps removed.
50 + - **Unsafe surface change:** 17 platform FFI in drag_out/. All justified: objc2 NSDraggingSession on macOS, COM IDataObject on Windows. No production unsafe outside FFI.
51 + - **Verification results:** sync/service.rs has zero production unwraps (all in #[cfg(test)]). theme.rs has zero production unwraps. Rhai engine has 100K ops + 32 call levels limits. note_counter is u64 (no overflow risk). DRAG_ACTIVE is in-memory AtomicBool (resets on restart, no persistence issue).
52 + - **New LOW findings:** sidebar.rs has 2 guarded unwraps (style nit). updater.rs trusts server-provided download URL (acceptable — opens in browser, no auto-install).
53 + - **Mandatory surprise:** sync/service.rs zero production unwraps — Impressive (see above).
54 +
55 + ### Eleventh audit (2026-03-18, Run 9 cross-project)
56 + - **Test count:** 566 (up from 557). 0 clippy warnings.
57 + - **Grade:** A- (maintained). v0.3.0.
58 + - **Clippy fixes:** 3 warnings resolved pre-release build: collapsible `if` in `file_list.rs` and `sidebar.rs`, derivable `Default` impl in `updater.rs`.
59 + - **Release build:** Standalone app + CLAP + VST3 all signed+notarized.
60 + - **OTA updater verified:** `updater.rs` (148 LOC) — proper error handling (15s timeout, version parsing fallback, target/arch detection), Arc-wrapped UpdateStatus for thread safety, 10s initial delay + 6h polling.
61 + - **No new findings.** All previous cold spots remain resolved.
62 + - **Mandatory surprise:** None. Previous surprises (applying_remote crash recovery, find_nodes_by_hashes column mismatch) all resolved.
63 +
64 + ### Concurrency Upgrade (2026-03-13)
65 + - **Concurrency:** A- -> A
66 + - Eliminated double-lock TOCTOU in cancel_import/cancel_analysis/cancel_export -- now uses single lock with .take() pattern (6 lock acquisitions reduced to 3).
67 +
68 + ### Observability Upgrade (2026-03-13)
69 + - **Observability:** A- -> A
70 + - Upgraded tracing subscriber from bare `fmt::init()` to `registry() + EnvFilter + fmt layer` with env-configurable log levels (RUST_LOG)
71 + - Added `features = ["env-filter"]` to `tracing-subscriber` workspace dependency
72 + - Added 12 `#[instrument(skip_all)]` annotations across 6 files: scheduler.rs (2), service.rs (6), auth.rs (1), audio.rs (1), import.rs (1), export.rs (1)
73 + - `cargo check --workspace` passes clean
74 +
75 + ### Adversarial Test Audit (2026-03-13)
76 +
77 + Targeted adversarial testing after seventh audit remediation. Test count: 532 → 546 (+14 tests). 4 critical bugs found and fixed:
78 +
79 + **CRITICAL: `find_nodes_by_hashes` SELECT had wrong column count**
80 + - Missing `s.cloud_only` column and `LEFT JOIN samples` in query
81 + - Query returned 4 columns but code expected 5, causing panic on result extraction
82 + - Fixed: Added missing column and join to match schema
83 + - Added 3 adversarial tests: non-existent hash, mismatched hash count, partial hash set
84 +
85 + **HIGH: `move_node` allowed circular parent chains**
86 + - No cycle detection before UPDATE — could create infinite loops
87 + - Fixed: Added ancestor chain walk with HashSet cycle detection before UPDATE
88 + - Added 3 tests: direct self-parent, indirect circular chain, valid deep move
89 +
90 + **HIGH: Zero-byte files accepted into content store**
91 + - No validation on file size before hashing and import
92 + - Fixed: Reject zero-byte files before hashing
93 + - Added 2 tests: zero-byte rejection, valid non-empty file acceptance
94 +
95 + **HIGH: Non-atomic remove ordered file deletion before DB**
96 + - Deleted file from disk first, then updated DB — crash between steps left orphan DB row
97 + - Fixed: Reversed order to DB first, then file (matches content store pattern)
98 + - Added 3 tests: remove_by_hash atomicity, remove_sample atomicity, remove rollback leaves file intact
99 +
100 + **Security & correctness improvements:**
101 + - All 4 findings had genuine data corruption or crash potential
102 + - Adversarial tests now cover: malformed queries, cycle creation, zero-byte edge case, crash-during-delete atomicity
103 + - DB version unchanged: v8 (hash re-verification migration from earlier audit still current)
104 +
105 + **Test breakdown:**
106 + - Core: 179 → 190 (+11 tests for the 4 fixes above)
107 + - Sync: 21 → 24 (+3 tests for edge cases)
108 +
109 + ### Eighth audit (2026-03-16, Run 6 cross-project)
110 + - **Test count:** 546 -> 557 (+11 tests)
111 + - **Grade:** A- (maintained).
112 + - **New findings (LOW):** unix_now().expect() in error.rs:134 could crash DAW host (should use non-panicking fallback). Only 12 #[instrument] annotations on 86 files — observability density gap.
113 + - **Mandatory surprise:** unix_now().expect() in CLAP/VST3 plugin context — Genuine issue (panic = DAW crash).
114 + - **Previous items verified:** All previous remediated items confirmed intact (applying_remote, browser tracing, import_screens split, theme paths).
115 +
116 + ### Seventh audit (2026-03-13, pre-launch skeptical lens)
117 +
118 + Grade holds at A-. 528 tests (was 518). Audio thread safety confirmed excellent. New minor findings:
119 + - Vec allocation in audio path for MIDI events (should be SmallVec/pre-allocated)
120 + - query_sample_field uses string interpolation for SQL column name (safe — values from match arms, not user input — but fragile if pattern copied)
121 + - No hash re-verification on read from content store (import validates, but read path trusts stored hash)
122 +
123 + Previous applying_remote crash recovery issue remains open (CRITICAL).
124 +
125 + **Post-audit remediation (2026-03-13):**
126 + - All 3 seventh-audit findings resolved: SmallVec for MIDI events, query_sample_field allowlist, hash re-verification
127 + - All 5 prior cold spots resolved: applying_remote cleared on startup, browser tracing migration, import_screens split, E2E tests (3), theme paths fixed
128 + - Test count: 528 -> 532 (+4 tests)
129 + - DB version: v7 -> v8
130 + - Documentation upgraded to A: SpectralFeatures fields documented with units, SampleStore struct doc added, architecture.md created (168 lines), README created (58 lines). All pub functions now have /// doc comments.
131 +
132 + ### Sixth-run full audit (2026-03-11)
133 +
134 + Fresh audit of entire codebase per audit.md. Test count: 518 (was 429). 2 trivial clippy warnings. Near-zero `.unwrap()` in production code. 2 `unsafe` blocks (both in test helpers, test-only). 7 crates (was 5).
135 +
136 + ### Major changes since fifth audit
137 +
138 + - **New crate: audiofiles-sync (1,838 LOC)** -- Full SyncKit integration: push/pull sync engine, OAuth2 PKCE auth, background scheduler, changelog triggers across 9 tables. 21 tests.
139 + - **New crate: audiofiles-rhai (1,462 LOC)** -- Rhai scripting for hardware sampler export profiles. Sandboxed engine, plugin discovery/loading, 4 hook points, bundled device plugins. 33 tests.
140 + - **Test count: 429 -> 518** -- New tests across sync (21), rhai (33), plus additional core/browser tests.
141 + - **LOC: ~22K -> 25.6K** -- Growth from sync and rhai crates.
142 + - **DB version: v6 -> v7** -- Migration 007 adds sync_state table, sync_changelog table, 27 changelog triggers.
143 + - **Backend trait: 42 -> 47 methods** -- Added sync-related methods (get/set VFS sync_files, sync manager integration).
144 + - **Alpha polish** -- VFS management UI (context menus, create/rename modals), directory management.
145 +
146 + ### Grades changed
147 +
148 + | Dimension | Previous | Current | Change |
149 + |-----------|:--------:|:-------:|--------|
150 + | Security | A | A- | applying_remote crash recovery gap |
151 + | Resilience | B+ | A- | Worker Drop cleanup improved, but applying_remote is new concern |
152 +
153 + ### Mandatory surprise assessment
154 +
155 + - **applying_remote flag stuck after crash**: Genuine issue. Silent data loss vector in sync system. Filed as CRITICAL action item.
156 +
157 + ### Audit Grade Corrections (2026-03-13)
158 +
159 + Corrected stale grades where the auditor missed existing code:
160 + - **Resilience:** A- → A. `applying_remote` cleared on startup (`service.rs:102-110`). All other resilience items confirmed working (Worker Drop, per-file error reporting, audio stream failure non-fatal, atomic migrations, CASCADE FKs).
161 + - **Frontend:** A- → A. import_screens split into directory module (configure.rs 229, progress.rs 177, tagging.rs 267). No single UI file exceeds 300 LOC.
162 +
163 + ### Security Deep Dive (2026-03-13) — Complete (2/2)
164 +
165 + - **Extension validation:** `store.rs` — `validate_extension()` function added, only allows alphanumeric characters, dots, and hyphens; prevents path traversal via crafted extensions. 3 new tests (path traversal, path separator, common extensions).
166 + - **OAuth callback safe slicing:** `auth.rs` — OAuth callback parser uses `.get(after_q..end)` for safe slicing instead of direct indexing; `saturating_sub` for fallback calculation; graceful break with `tracing::warn!` on malformed requests.
167 +
168 + ### Still open (5 items)
169 +
170 + - Add `updated_at` columns for sync (Phase 9 prerequisite)
171 + - Consider BrowserState decomposition (40+ field god object)
172 + - app crate: 22 tests now cover updater, key management, icon, audio; remaining gaps are platform-dependent (eframe, cpal, tray)
173 + - Add tests for remaining UI modules (file_list.rs, widgets.rs)
174 + - drag_out/ has no automated tests (platform FFI, manual testing only)
175 +
176 + ## Resolved Items (Previous Audits)
177 +
178 + - [x] state.rs split into directory module (state/mod.rs, navigation.rs, import_workflow.rs, bulk_ops.rs, tests.rs)
179 + - [x] 132 integration tests for state.rs orchestration
180 + - [x] LIKE wildcards escaped via `escape_like()` in search.rs and tags.rs
181 + - [x] DB migrations wrapped in transactions
182 + - [x] Hash validation in store.rs (validate_hash checks 64-char lowercase hex)
183 + - [x] `Result<_, String>` in app/audio.rs replaced with typed AudioError enum
184 + - [x] contents.clone() in file_list.rs confirmed as Arc clone (not deep copy)
185 + - [x] All clippy lints resolved (items_after_test_module, io_other_error, identity_op)
186 + - [x] Moved load_analysis to core (was raw SQL in DirectBackend)
187 + - [x] Moved sample_extension/original_name to core
188 + - [x] Replaced manual transactions with Database::transaction()
189 + - [x] Cleaned up find_nodes_by_hashes
190 + - [x] `export/mod.rs` split (extracted resolve.rs, runner.rs)
191 + - [x] SAFETY comments on unsafe blocks (preview.rs, instrument.rs)
192 + - [x] Scalability notes on fingerprint.rs and similarity.rs (O(n) documented; fingerprint now VP-tree indexed)
193 + - [x] Tag validation docs enhanced
194 + - [x] Theme tests: 44 tests for ui/theme.rs pure functions
195 + - [x] `split_name_ext` centralized into util.rs (3 tests)
196 + - [x] Smart folder error handling: CoreError::Serialization variant
197 + - [x] Entity ID newtypes (VfsId, NodeId, SmartFolderId, CollectionId)
198 + - [x] SampleHash validated newtype with new() returning Result
199 + - [x] NodeType::parse() wildcard fallback replaced with explicit error
200 + - [x] SampleHash consistency fix (ExportItem, ReviewItem)
@@ -105,202 +105,9 @@ Previously resolved:
105 105 | Unwrap (prod) | ~1 | 7 (all init) | 7 (all init) | 2 (sidebar, guarded) | 2 (sidebar, guarded) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) | 2 (sidebar) |
106 106 | Unsafe | 2 (test) | 2 (test) | 2 (test) | 2 (test) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) | 17 (FFI) |
107 107
108 - ## Changes Since Last Audit
109 -
110 - ### Sixteenth audit (2026-03-28, Run 12 cross-project)
111 - - **Test count:** 611. 0 clippy warnings. 0 failures.
112 - - **Grade:** A (maintained). v0.3.0.
113 - - **No code changes since ML classifier (2026-03-26).**
114 - - **New dependency advisory:** rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`.
115 - - **Mandatory surprise:** None. Previous surprises (sync/service.rs zero production unwraps, applying_remote crash recovery) all resolved.
116 - - **No new code findings.** All previous items remain resolved.
117 -
118 - ### Two-Layer ML Classifier (2026-03-26)
119 - - **Test count:** 585 -> 610 (+25). 0 clippy warnings.
120 - - **DB version:** v10 -> v11 (migration 011: `classification_confidence REAL` column on `audio_analysis`, sync triggers updated).
121 - - **New module:** `analysis/mfcc.rs` — MFCC feature extraction from existing STFT magnitudes. 26-band mel filterbank, DCT-II, 13 coefficients aggregated as mean+variance. 7 unit tests.
122 - - **Rewritten:** `analysis/classify.rs` — Two-layer ML classifier replacing rule-based `classify_full()`. Layer 1: rule-based broad classifier (Drum/Bass/Vocal/Synth/Pad/Fx/Noise/Music/Ambience/Impact/Foley/Texture). Layer 2: 200-tree Random Forest for drum sub-classification. New types: `BroadClass`, `ClassificationResult`, `TreeNode`, `RandomForestModel`. 35-feature vector (9 scalar + 26 MFCC). Model embedded via `include_bytes!` (7.4MB), lazy init via `OnceLock`. 5 new tests.
123 - - **New crate:** `audiofiles-train` — RF training binary (excluded from default-members). Custom implementation (no linfa dependency). Stratified 5-fold CV, rayon parallel training, JSON serialization.
124 - - **Modified:** `analysis/spectral.rs` — Added `compute_spectral_features_with_frames()` returning magnitude vectors alongside features. Original function delegates and discards frames.
125 - - **Modified:** `analysis/mod.rs` — Pipeline now computes MFCCs from STFT frames, builds extended ClassifyInput, calls `classify_ml()`. `AnalysisResult` gains `classification_confidence: Option<f64>`.
126 - - **Modified:** `analysis/suggest.rs` — Confidence-gated tag suggestions (only suggest if ML confidence >= 0.5). Reason string includes ML confidence percentage.
127 - - **Modified:** `classify_validation.rs` — Reports per-class confidence stats (mean/median/P10) and precision/recall/F1.
128 - - **Accuracy:** 94.4% strict / 95.6% lenient on 4343 labeled drum samples (was 36%/61% rule-based).
129 - - **Model file:** `crates/audiofiles-core/models/layer2_drum.json` (7.4MB, 200 trees, max_depth=25).
130 - - **No grade change expected** — all new code follows existing patterns, clippy clean, comprehensive tests.
131 -
132 - ### Fourteenth audit (2026-03-22, app test coverage)
133 - - **Test count:** 560 -> 585 (+25). 0 clippy warnings.
134 - - **Grade:** A (maintained). audiofiles-app Test B -> A-.
135 - - **updater.rs:** 8 new tests — UpdateStatus default, dismiss/should_show state machine (4 states), UpdateResponse deserialization, CURRENT_VERSION semver validation.
136 - - **main.rs:** 7 new tests — load_api_key (from file, trims whitespace, empty/whitespace-only/missing file), save_api_key, save-and-load roundtrip.
137 - - **tray.rs:** 2 new tests — build_icon produces valid 18x18 RGBA, icon buffer dimensions and pixel content verification. `build_icon` changed from `fn` to `pub(crate) fn` for testability.
138 - - **Still open (audiofiles-app):** eframe UI update loop, start_output_stream, AppTray::new/poll — all platform-dependent, inherently hard to unit test.
139 -
140 - ### Thirteenth audit (2026-03-19, VP-tree addition)
141 - - **Test count:** 560 (+25). 0 clippy warnings.
142 - - **Grade:** A (maintained). Performance A- -> A. v0.3.0.
143 - - **VP-tree module:** New `vp_tree.rs` — generic `VpTree<T>` with `build()`, `find_nearest()`, `find_within()`. 10 tests including brute-force correctness check.
144 - - **FingerprintIndex:** Two-phase near-duplicate search. Phase 1: VP-tree on 16-bin compact Euclidean features (proper metric, O(log n)). Phase 2: full NCC verification on candidates. Build < 1s for 100K. Query ~3ms at 100K (was 2.7s linear scan — ~900x speedup). 4 tests.
145 - - **SimilarityIndex:** VP-tree on fixed-normalization Euclidean features. Ranges computed once from dataset (not per-query). Minor semantic change: single new sample no longer shifts normalization of all others. Sub-ms queries at 100K (was 26ms linear scan). 5 tests.
146 - - **DirectBackend integration:** Both indexes cached as `Mutex<Option<...>>`, lazy build on first query, auto-invalidation on `save_analysis`.
147 -
148 - ### Twelfth audit (2026-03-19, AF-focused)
149 - - **Test count:** 535. 0 clippy warnings.
150 - - **Grade:** A- -> A. v0.3.0.
151 - - **Major additions:** Native macOS drag-out (drag_out/ module: mod.rs, macos.rs, windows.rs), brand refresh (audiofiles theme, af/ logo in Recursive font, app icon with squircle), description.md rewrite.
152 - - **Crate removal:** audiofiles-plugin, audiofiles-ipc, and xtask removed (standalone-only). 7 crates -> 5. nih-plug workspace deps removed.
153 - - **Unsafe surface change:** 17 platform FFI in drag_out/. All justified: objc2 NSDraggingSession on macOS, COM IDataObject on Windows. No production unsafe outside FFI.
154 - - **Verification results:** sync/service.rs has zero production unwraps (all in #[cfg(test)]). theme.rs has zero production unwraps. Rhai engine has 100K ops + 32 call levels limits. note_counter is u64 (no overflow risk). DRAG_ACTIVE is in-memory AtomicBool (resets on restart, no persistence issue).
155 - - **New LOW findings:** sidebar.rs has 2 guarded unwraps (style nit). updater.rs trusts server-provided download URL (acceptable — opens in browser, no auto-install).
156 - - **Mandatory surprise:** sync/service.rs zero production unwraps — Impressive (see above).
157 -
158 - ### Eleventh audit (2026-03-18, Run 9 cross-project)
159 - - **Test count:** 566 (up from 557). 0 clippy warnings.
160 - - **Grade:** A- (maintained). v0.3.0.
161 - - **Clippy fixes:** 3 warnings resolved pre-release build: collapsible `if` in `file_list.rs` and `sidebar.rs`, derivable `Default` impl in `updater.rs`.
162 - - **Release build:** Standalone app + CLAP + VST3 all signed+notarized.
163 - - **OTA updater verified:** `updater.rs` (148 LOC) — proper error handling (15s timeout, version parsing fallback, target/arch detection), Arc-wrapped UpdateStatus for thread safety, 10s initial delay + 6h polling.
164 - - **No new findings.** All previous cold spots remain resolved.
165 - - **Mandatory surprise:** None. Previous surprises (applying_remote crash recovery, find_nodes_by_hashes column mismatch) all resolved.
166 -
167 - ### Concurrency Upgrade (2026-03-13)
168 - - **Concurrency:** A- -> A
169 - - Eliminated double-lock TOCTOU in cancel_import/cancel_analysis/cancel_export -- now uses single lock with .take() pattern (6 lock acquisitions reduced to 3).
170 -
171 - ### Observability Upgrade (2026-03-13)
172 - - **Observability:** A- -> A
173 - - Upgraded tracing subscriber from bare `fmt::init()` to `registry() + EnvFilter + fmt layer` with env-configurable log levels (RUST_LOG)
174 - - Added `features = ["env-filter"]` to `tracing-subscriber` workspace dependency
175 - - Added 12 `#[instrument(skip_all)]` annotations across 6 files: scheduler.rs (2), service.rs (6), auth.rs (1), audio.rs (1), import.rs (1), export.rs (1)
176 - - `cargo check --workspace` passes clean
177 -
178 - ### Adversarial Test Audit (2026-03-13)
179 -
180 - Targeted adversarial testing after seventh audit remediation. Test count: 532 → 546 (+14 tests). 4 critical bugs found and fixed:
181 -
182 - **CRITICAL: `find_nodes_by_hashes` SELECT had wrong column count**
183 - - Missing `s.cloud_only` column and `LEFT JOIN samples` in query
184 - - Query returned 4 columns but code expected 5, causing panic on result extraction
185 - - Fixed: Added missing column and join to match schema
186 - - Added 3 adversarial tests: non-existent hash, mismatched hash count, partial hash set
187 -
188 - **HIGH: `move_node` allowed circular parent chains**
189 - - No cycle detection before UPDATE — could create infinite loops
190 - - Fixed: Added ancestor chain walk with HashSet cycle detection before UPDATE
191 - - Added 3 tests: direct self-parent, indirect circular chain, valid deep move
192 -
193 - **HIGH: Zero-byte files accepted into content store**
194 - - No validation on file size before hashing and import
195 - - Fixed: Reject zero-byte files before hashing
196 - - Added 2 tests: zero-byte rejection, valid non-empty file acceptance
197 -
198 - **HIGH: Non-atomic remove ordered file deletion before DB**
199 - - Deleted file from disk first, then updated DB — crash between steps left orphan DB row
200 - - Fixed: Reversed order to DB first, then file (matches content store pattern)
201 - - Added 3 tests: remove_by_hash atomicity, remove_sample atomicity, remove rollback leaves file intact
202 -
203 - **Security & correctness improvements:**
204 - - All 4 findings had genuine data corruption or crash potential
205 - - Adversarial tests now cover: malformed queries, cycle creation, zero-byte edge case, crash-during-delete atomicity
206 - - DB version unchanged: v8 (hash re-verification migration from earlier audit still current)
207 -
208 - **Test breakdown:**
209 - - Core: 179 → 190 (+11 tests for the 4 fixes above)
210 - - Sync: 21 → 24 (+3 tests for edge cases)
211 -
212 - ### Eighth audit (2026-03-16, Run 6 cross-project)
213 - - **Test count:** 546 -> 557 (+11 tests)
214 - - **Grade:** A- (maintained).
215 - - **New findings (LOW):** unix_now().expect() in error.rs:134 could crash DAW host (should use non-panicking fallback). Only 12 #[instrument] annotations on 86 files — observability density gap.
216 - - **Mandatory surprise:** unix_now().expect() in CLAP/VST3 plugin context — Genuine issue (panic = DAW crash).
217 - - **Previous items verified:** All previous remediated items confirmed intact (applying_remote, browser tracing, import_screens split, theme paths).
218 -
219 - ### Seventh audit (2026-03-13, pre-launch skeptical lens)
220 -
221 - Grade holds at A-. 528 tests (was 518). Audio thread safety confirmed excellent. New minor findings:
222 - - Vec allocation in audio path for MIDI events (should be SmallVec/pre-allocated)
223 - - query_sample_field uses string interpolation for SQL column name (safe — values from match arms, not user input — but fragile if pattern copied)
224 - - No hash re-verification on read from content store (import validates, but read path trusts stored hash)
225 -
226 - Previous applying_remote crash recovery issue remains open (CRITICAL).
227 -
228 - **Post-audit remediation (2026-03-13):**
229 - - All 3 seventh-audit findings resolved: SmallVec for MIDI events, query_sample_field allowlist, hash re-verification
230 - - All 5 prior cold spots resolved: applying_remote cleared on startup, browser tracing migration, import_screens split, E2E tests (3), theme paths fixed
231 - - Test count: 528 -> 532 (+4 tests)
232 - - DB version: v7 -> v8
233 - - Documentation upgraded to A: SpectralFeatures fields documented with units, SampleStore struct doc added, architecture.md created (168 lines), README created (58 lines). All pub functions now have /// doc comments.
234 -
235 - ### Sixth-run full audit (2026-03-11)
236 -
237 - Fresh audit of entire codebase per audit.md. Test count: 518 (was 429). 2 trivial clippy warnings. Near-zero `.unwrap()` in production code. 2 `unsafe` blocks (both in test helpers, test-only). 7 crates (was 5).
238 -
239 - ### Major changes since fifth audit
240 -
241 - - **New crate: audiofiles-sync (1,838 LOC)** -- Full SyncKit integration: push/pull sync engine, OAuth2 PKCE auth, background scheduler, changelog triggers across 9 tables. 21 tests.
242 - - **New crate: audiofiles-rhai (1,462 LOC)** -- Rhai scripting for hardware sampler export profiles. Sandboxed engine, plugin discovery/loading, 4 hook points, bundled device plugins. 33 tests.
243 - - **Test count: 429 -> 518** -- New tests across sync (21), rhai (33), plus additional core/browser tests.
244 - - **LOC: ~22K -> 25.6K** -- Growth from sync and rhai crates.
245 - - **DB version: v6 -> v7** -- Migration 007 adds sync_state table, sync_changelog table, 27 changelog triggers.
246 - - **Backend trait: 42 -> 47 methods** -- Added sync-related methods (get/set VFS sync_files, sync manager integration).
247 - - **Alpha polish** -- VFS management UI (context menus, create/rename modals), directory management.
248 -
249 - ### Grades changed
250 -
251 - | Dimension | Previous | Current | Change |
252 - |-----------|:--------:|:-------:|--------|
253 - | Security | A | A- | applying_remote crash recovery gap |
254 - | Resilience | B+ | A- | Worker Drop cleanup improved, but applying_remote is new concern |
255 -
256 - ### Mandatory surprise assessment
257 -
258 - - **applying_remote flag stuck after crash**: Genuine issue. Silent data loss vector in sync system. Filed as CRITICAL action item.
259 -
260 - ### Audit Grade Corrections (2026-03-13)
261 -
262 - Corrected stale grades where the auditor missed existing code:
263 - - **Resilience:** A- → A. `applying_remote` cleared on startup (`service.rs:102-110`). All other resilience items confirmed working (Worker Drop, per-file error reporting, audio stream failure non-fatal, atomic migrations, CASCADE FKs).
264 - - **Frontend:** A- → A. import_screens split into directory module (configure.rs 229, progress.rs 177, tagging.rs 267). No single UI file exceeds 300 LOC.
265 -
266 - ### Security Deep Dive (2026-03-13) — Complete (2/2)
267 -
268 - - **Extension validation:** `store.rs` — `validate_extension()` function added, only allows alphanumeric characters, dots, and hyphens; prevents path traversal via crafted extensions. 3 new tests (path traversal, path separator, common extensions).
269 - - **OAuth callback safe slicing:** `auth.rs` — OAuth callback parser uses `.get(after_q..end)` for safe slicing instead of direct indexing; `saturating_sub` for fallback calculation; graceful break with `tracing::warn!` on malformed requests.
270 -
271 - ### Still open (5 items)
272 -
273 - - Add `updated_at` columns for sync (Phase 9 prerequisite)
274 - - Consider BrowserState decomposition (40+ field god object)
275 - - app crate: 22 tests now cover updater, key management, icon, audio; remaining gaps are platform-dependent (eframe, cpal, tray)
276 - - Add tests for remaining UI modules (file_list.rs, widgets.rs)
277 - - drag_out/ has no automated tests (platform FFI, manual testing only)
278 -
279 - ## Resolved Items (Previous Audits)
280 -
281 - - [x] state.rs split into directory module (state/mod.rs, navigation.rs, import_workflow.rs, bulk_ops.rs, tests.rs)
282 - - [x] 132 integration tests for state.rs orchestration
283 - - [x] LIKE wildcards escaped via `escape_like()` in search.rs and tags.rs
284 - - [x] DB migrations wrapped in transactions
285 - - [x] Hash validation in store.rs (validate_hash checks 64-char lowercase hex)
286 - - [x] `Result<_, String>` in app/audio.rs replaced with typed AudioError enum
287 - - [x] contents.clone() in file_list.rs confirmed as Arc clone (not deep copy)
288 - - [x] All clippy lints resolved (items_after_test_module, io_other_error, identity_op)
289 - - [x] Moved load_analysis to core (was raw SQL in DirectBackend)
290 - - [x] Moved sample_extension/original_name to core
291 - - [x] Replaced manual transactions with Database::transaction()
292 - - [x] Cleaned up find_nodes_by_hashes
293 - - [x] `export/mod.rs` split (extracted resolve.rs, runner.rs)
294 - - [x] SAFETY comments on unsafe blocks (preview.rs, instrument.rs)
295 - - [x] Scalability notes on fingerprint.rs and similarity.rs (O(n) documented; fingerprint now VP-tree indexed)
296 - - [x] Tag validation docs enhanced
297 - - [x] Theme tests: 44 tests for ui/theme.rs pure functions
298 - - [x] `split_name_ext` centralized into util.rs (3 tests)
299 - - [x] Smart folder error handling: CoreError::Serialization variant
300 - - [x] Entity ID newtypes (VfsId, NodeId, SmartFolderId, CollectionId)
301 - - [x] SampleHash validated newtype with new() returning Result
302 - - [x] NodeType::parse() wildcard fallback replaced with explicit error
303 - - [x] SampleHash consistency fix (ExportItem, ReviewItem)
108 + ---
109 +
110 + See [audit_history.md](./audit_history.md) for full chronological audit log.
304 111
305 112 ---
306 113
@@ -1,6 +1,6 @@
1 1 # audiofiles -- Competitive Analysis
2 2
3 - Last updated: 2026-03-18
3 + Last updated: 2026-03-30
4 4
5 5 ## Positioning
6 6
@@ -28,8 +28,7 @@ audiofiles is the only sample manager with content-addressed storage, multiple v
28 28 | **VFS (multiple hierarchies)** | Yes | No | No | No | No | No | No | No |
29 29 | **Hardware export profiles** | Yes (14) | No | No | No | No | No | No | No |
30 30 | **Rhai scripting** | Yes | No | No | No | No | No | No | No |
31 - | **CLAP plugin** | Yes | No | No | No | No | No | No | No |
32 - | **VST3 plugin** | Yes | Yes | Yes | No | Yes | Yes | No | No |
31 + | **ML classification (16-class)** | Yes | No | No | No | No | No | Yes | Separate ($10) |
33 32 | **Standalone** | Yes | App | Yes | App | Yes | Yes | Yes | Yes |
34 33 | **Source-available** | Yes | No | No | No | No | No | No | No |
35 34 | **Local-first** | Yes | No | Partial | No | Yes | Yes | Yes | Yes |
@@ -38,7 +37,7 @@ audiofiles is the only sample manager with content-addressed storage, multiple v
38 37 | **Key detection** | Yes | Metadata | No | Metadata | No | No | Yes | Yes |
39 38 | **LUFS loudness** | Yes | No | No | No | No | No | No | No |
40 39 | **Spectral analysis** | Yes | No | No | No | No | No | Yes | No |
41 - | **Classification (12-class)** | Yes | No | No | No | No | No | Yes | Separate ($10) |
40 + | **Classification (16-class)** | Yes | No | No | No | No | No | Yes | Separate ($10) |
42 41 | **Loop detection** | Yes | No | No | No | No | No | No | No |
43 42 | **Tag suggestions** | Yes | No | Smart tags | No | No | No | Yes (v1.6) | No |
44 43 | **Bulk ops with undo** | Yes | No | No | No | No | No | No | Yes (rename) |
@@ -229,6 +228,7 @@ Features that were previously missing and are now implemented:
229 228 - **Near-duplicate detection** -- Peak envelope fingerprint comparison. "Find Duplicates" context menu. [Done]
230 229 - **Cloud sync** -- E2E encrypted push/pull via MNW SyncKit, per-VFS file sync toggle, cloud-only browsing. [Done]
231 230 - **MIDI instrument mode** -- Chromatic + multi-sample, ADSR, 8-voice polyphony, drag-to-keyboard zone assignment. [Done]
231 + - **ML-based categorization** -- Two-layer system: rule-based broad classifier + 200-tree Random Forest for drum sub-classification. 16 categories, 94.4% accuracy on labeled drums. [Done]
232 232
233 233 ## Remaining Gaps
234 234
@@ -239,7 +239,6 @@ Features present in multiple competitors that audiofiles still lacks, grouped by
239 239 - **Auto tempo/key-matched preview** -- Splice Bridge and Loopcloud auto-stretch to project BPM/key. Requires time-stretch library.
240 240 - **Preview effects (filter, pitch)** -- ADSR has HP/LP filter, fade, gain, normalize baked into drag-to-DAW output. Loopcloud has 10 built-in effects. HP/LP filter on preview would be a small, useful addition. Full FX chains are bloat.
241 241 - **Visual similarity map (2D point cloud)** -- XO, Atlas 2, Sononym. Visually compelling for exploration. Non-trivial in egui but high wow-factor.
242 - - **ML-based categorization** -- Sononym, XO, Atlas 2, AudioFinder/AudioCortex. Rule-based classification covers 12 categories today. ML would improve accuracy and expand categories.
243 242 - **Spectrogram visualization** -- Sononym, AudioFinder. Frequency-over-time display per sample.
244 243
245 244 ### Low Priority / Out of Scope
@@ -247,11 +246,10 @@ Features present in multiple competitors that audiofiles still lacks, grouped by
247 246 - **Built-in sequencer / pattern editor** -- XO and Atlas 2 have drum sequencers. DAW territory; not in AF's scope.
248 247 - **Cloud sample marketplace / storefront** -- Splice and Loopcloud each have 4M+ royalty-free samples. Requires massive catalog licensing, content moderation, and ongoing operational cost. Contradicts audiofiles' local-first philosophy. The planned community sharing feature (deferred) is a lighter alternative.
249 248 - **Cloud storage** -- Splice and Loopcloud sync samples to cloud. AF has its own optional sync tier planned, but cloud-as-primary contradicts local-first philosophy.
250 - - **Mobile app** -- Splice has iOS/Android. Out of scope until core desktop/plugin experience is complete. Mobile tagging has niche value for field recording workflows. [Planned, Deferred]
251 - - **Sample editing (trim, loop, fade, slice)** -- AudioFinder, Loopcloud. DAW territory. AF should remain non-destructive and metadata-focused. Export-time transformations (device profiles) are the right approach.
252 - - **Batch processing / DSP** -- AudioFinder, Loopcloud. Same reasoning as sample editing. Batch format conversion is already covered by device export profiles.
249 + - **Mobile app** -- Splice has iOS/Android. Out of scope until desktop experience is mature. [Deferred]
250 + - **Sample editing (trim, loop, fade, slice)** -- AudioFinder, Loopcloud. Planned as sample forge phases (10-16 in todo.md). Export-time transformations via device profiles cover the basic case.
251 + - **Batch processing / DSP** -- AudioFinder, Loopcloud. Planned as batch forge phase (15 in todo.md).
253 252 - **Full effects chain on preview** -- Bloat for a manager.
254 - - **AAX format** -- ADSR and AudioFinder support it. Low priority vs CLAP/VST3.
255 253 - **Social features / sharing / community** -- Premature, low signal.
256 254
257 255 ## What We Offer That Competitors Don't
@@ -259,7 +257,7 @@ Features present in multiple competitors that audiofiles still lacks, grouped by
259 257 - **Content-addressed storage with SHA-256 deduplication** -- Samples stored by hash, never duplicated regardless of how many VFS trees reference them. No other tool does this.
260 258 - **Multiple Virtual File Systems** -- Organize the same samples into unlimited hierarchies without copying files. All operations are metadata-only (instant moves, renames, multi-tree views).
261 259 - **Hardware sampler export profiles** -- Rhai-scripted export to 10+ devices (M8, Digitakt, Digitakt II, SP-404 MKII, MPC, Polyend Tracker, Deluge, Blackbox, Volca Sample 2, OP-1) with proper sample rate conversion, bit depth dithering, and filename sanitization. No competitor offers this.
262 - - **7 local analysis types** -- BPM, key, LUFS, spectral (centroid/flatness/rolloff/ZCR), classification, loop detection, tag suggestions. Splice and Loopcloud rely on catalog metadata; Sononym has ML similarity but fewer distinct analysis types.
260 + - **8 local analysis types** -- BPM, key, LUFS, spectral (centroid/flatness/rolloff/ZCR), MFCC, ML classification (16 categories), loop detection, tag suggestions. Splice and Loopcloud rely on catalog metadata; Sononym has ML similarity but fewer distinct analysis types.
263 261 - **Tag suggestion engine with confidence scores** -- Analyzes audio characteristics and suggests tags with reasoning. Sononym v1.6 added tagging but without confidence scoring.
264 262 - **Bulk operations with 50-deep undo** -- Delete, move, rename, tag operations all undoable. No competitor has this depth of undo.
265 263 - **Rhai scripting for extensibility** -- Export profiles, future import adapters. Community-extensible via plugin.toml manifests + optional scripts in a sandboxed runtime. No other sample manager has a scripting runtime.
@@ -270,16 +268,10 @@ Features present in multiple competitors that audiofiles still lacks, grouped by
270 268
271 269 ## Key Dynamics
272 270
273 - - Splice's native DAW integrations (embedded in Pro Tools, Studio One, Ableton without a plugin) represent a shift in the market. AF's plugin approach is the standard; direct DAW embedding is the future for marketplace products.
274 - - Sononym v1.6 (2025) added tagging and UCS integration, closing the gap on AF's tag system, but AF's confidence-scored suggestion engine and content-addressed storage remain unique.
275 - - The drag-and-drop blocker is the most impactful missing feature. Copy-path-to-clipboard is a friction point that every competitor has solved.
276 - - The most important gaps to close (in priority order):
277 - 1. Waveform display -- table stakes, already planned
278 - 2. Collections -- already planned, high usability impact
279 - 3. Similarity search -- already planned, major discovery feature
280 - 4. True drag-and-drop to DAW -- already planned (blocked on baseview)
281 - 5. Preview pitch/tempo matching -- not yet planned, high value in plugin mode
282 - 6. Near-duplicate detection -- extends existing exact dedup with fuzzy matching
271 + - Splice's native DAW integrations (embedded in Pro Tools, Studio One, Ableton without a plugin) represent a shift in the market. Direct DAW embedding is the future for marketplace products, but irrelevant for standalone managers.
272 + - Sononym v1.6 (2025) added tagging and UCS integration, closing the gap on AF's tag system, but AF's confidence-scored ML suggestion engine and content-addressed storage remain unique.
273 + - All major feature gaps from the initial analysis have been closed (drag-to-DAW, waveform, collections, similarity, near-duplicate detection, ML classification, MIDI instrument). Remaining gaps are in the "nice to have" category: preview effects, visual similarity map, spectrogram.
274 + - The competitive moat is the combination of features no single competitor matches: content-addressed storage + VFS + hardware export + ML classification + Rhai scripting + cloud sync. Each individual feature has a competitor; the combination doesn't.
283 275
284 276 ## Target Users
285 277
@@ -47,8 +47,10 @@ audiofiles is a standalone desktop sample manager. It stores samples by content
47 47 - Progress bar with cancel support
48 48 - Re-analyze previously analyzed samples
49 49
50 - ### Classification (12 Categories)
51 - Kick, Snare, HiHat, Cymbal, Percussion, Bass, Vocal, Synth, Pad, FX, Noise, Music
50 + ### Classification (16 Categories)
51 + Kick, Snare, HiHat, Cymbal, Percussion, Bass, Vocal, Synth, Pad, FX, Noise, Music, Ambience, Impact, Foley, Texture
52 +
53 + Two-layer ML system: rule-based broad classifier (Layer 1) + 200-tree Random Forest for drum sub-classification (Layer 2). 94.4% accuracy on labeled drum samples.
52 54
53 55 ### Tag System
54 56 - Hierarchical dot-notation tags (e.g., `genre.electronic.house`, `instrument.drum.kick`)
@@ -193,7 +195,7 @@ Kick, Snare, HiHat, Cymbal, Percussion, Bass, Vocal, Synth, Pad, FX, Noise, Musi
193 195 - OTA update notifications
194 196
195 197 ### Data Storage
196 - - SQLite database (schema v8, 11 tables, migrations)
198 + - SQLite database (versioned migrations)
197 199 - Content-addressed sample store (files named by SHA-256 hash)
198 200 - Waveform data cached in database
199 201 - Database local to the app
@@ -204,11 +206,11 @@ Kick, Snare, HiHat, Cymbal, Percussion, Bass, Vocal, Synth, Pad, FX, Noise, Musi
204 206
205 207 ## Price
206 208
207 - Free (alpha). Source-available (PolyForm Noncommercial 1.0.0).
209 + License key required. Sold via Makenot.work store. Source-available (PolyForm Noncommercial 1.0.0).
208 210
209 211 ## Tags
210 212
211 - Music Production, Samples, Audio, DAW Plugin, Sample Manager, Hardware Sampler, Rust
213 + Music Production, Samples, Audio, Sample Manager, Hardware Sampler, Rust
212 214
213 215 ---
214 216
@@ -216,10 +218,10 @@ Music Production, Samples, Audio, DAW Plugin, Sample Manager, Hardware Sampler,
216 218
217 219 ### Tech Stack
218 220
219 - - **Language:** Rust (2021 edition), pure Rust with zero `unsafe` in production code
221 + - **Language:** Rust (2021 edition), zero `unsafe` in production code (17 justified FFI blocks in platform drag-out)
220 222 - **Audio Decoding:** Symphonia 0.5.5
221 223 - **Analysis:** stratum-dsp 1.0 (BPM/key), bs1770 (LUFS), realfft 3.5 (spectral)
222 - - **Database:** SQLite via rusqlite 0.31 (bundled, schema v8)
224 + - **Database:** SQLite via rusqlite 0.31 (bundled)
223 225 - **Hashing:** SHA-256 (content-addressed storage)
224 226 - **GUI:** eframe (egui windowed) + cpal 0.15 (audio output)
225 227 - **Export Scripting:** Rhai 1.21 (sandboxed: 100K ops, 32-level calls)
@@ -230,14 +232,11 @@ Music Production, Samples, Audio, DAW Plugin, Sample Manager, Hardware Sampler,
230 232
231 233 ### Testing
232 234
233 - - **560 tests** across the workspace
234 - - Core (audiofiles-core): database, store, VFS, tags, analysis, search, export, instrument, smart folders, rename, fingerprint, similarity
235 - - Browser (audiofiles-browser): state orchestration (selection, bulk ops, import/analysis, navigation, rename, column config), theme parsing, preview decoding, export pipeline
236 - - Integration: full pipeline (import, analyze, search, tag, export), analysis roundtrip, combined filter search
235 + Tests cover: database, store, VFS, tags, analysis (incl. ML classifier), search, export, instrument, smart folders, rename, fingerprint, similarity, state orchestration, theme parsing, preview decoding, updater, license activation, and full end-to-end pipelines.
237 236
238 237 ### Status
239 238
240 - All pre-beta phases complete (0-9F). 560 tests. Grade A (all dimensions). Ready for private alpha testing.
239 + All pre-beta phases complete. Audit grade A. v0.3.0, standalone-only. License key activation required.
241 240
242 241 ---
243 242
@@ -0,0 +1,246 @@
1 + # Plugin Authoring Guide
2 +
3 + audiofiles uses a plugin system for device-aware audio export. Plugins describe hardware sampler constraints (formats, sample rates, bit depths, channels, naming rules) via TOML manifests and optionally run Rhai scripts at four hook points during export.
4 +
5 + 14 device profiles ship bundled. You can add your own.
6 +
7 + ## Plugin Structure
8 +
9 + A plugin is a directory containing a `manifest.toml` and optional Rhai hook scripts:
10 +
11 + ```
12 + my-sampler/
13 + manifest.toml # Required — device constraints
14 + hooks/ # Optional
15 + validate.rhai # Filter samples before export
16 + transform.rhai # Rename files during export
17 + pre.rhai # Run before export batch
18 + post.rhai # Run after export batch
19 + ```
20 +
21 + ## Plugin Locations
22 +
23 + **Bundled** (compiled into the binary): `crates/audiofiles-rhai/plugins/bundled/<device-slug>/`
24 +
25 + 14 bundled devices: SP-404 MKII, MPC, Digitakt, Digitakt II, Octatrack, OP-1, Deluge, Model:Samples, Polyend Tracker, Circuit Rhythm, Maschine+, M8, Blackbox, Volca Sample 2.
26 +
27 + **User plugins** (loaded at runtime): `~/.config/audiofiles/plugins/user/<plugin-name>/`
28 +
29 + User plugins override bundled plugins with the same name (case-insensitive lookup).
30 +
31 + ## Manifest Format
32 +
33 + ```toml
34 + [device]
35 + name = "SP-404 MKII"
36 + manufacturer = "Roland"
37 + version = "1.0"
38 +
39 + [audio]
40 + formats = ["wav", "aiff"] # Supported export formats
41 + sample_rates = [44100, 48000] # Supported sample rates (Hz)
42 + bit_depths = [16, 24] # Supported bit depths
43 + channels = "both" # "mono" | "stereo" | "both"
44 +
45 + [naming] # Optional
46 + case = "upper" # "lower" | "upper" | "original"
47 + separator = "_" # Word boundary character
48 + max_length = 12 # Max filename length (stem only)
49 + strip_special = true # Remove non-alphanumeric chars
50 +
51 + [limits] # Optional
52 + max_file_size_bytes = 134217728 # File size cap in bytes
53 + max_sample_count = 500 # Max samples on device
54 +
55 + [hooks] # Optional — paths relative to plugin dir
56 + validate_sample = "hooks/validate.rhai"
57 + transform_filename = "hooks/transform.rhai"
58 + pre_export = "hooks/pre.rhai"
59 + post_export = "hooks/post.rhai"
60 + ```
61 +
62 + ### Required Sections
63 +
64 + **`[device]`** — Name, manufacturer, and version string. The name is what appears in the export UI and is used for lookup (case-insensitive).
65 +
66 + **`[audio]`** — Export constraints. Formats: `wav`, `aiff`. Channels: `mono`, `stereo`, or `both`. Sample rates and bit depths are integer arrays.
67 +
68 + ### Optional Sections
69 +
70 + **`[naming]`** — Filename rules applied during export. If omitted, filenames pass through unchanged.
71 +
72 + **`[limits]`** — Hardware capacity constraints. Used to warn or prevent over-exporting.
73 +
74 + **`[hooks]`** — Paths to Rhai scripts, relative to the plugin directory. Path traversal outside the plugin directory is blocked.
75 +
76 + ## Rhai Hooks
77 +
78 + Four hook points, all optional. Hooks are compiled once when the plugin loads and executed during export.
79 +
80 + ### `validate_sample` — Filter samples
81 +
82 + Called once per sample before export. Return `true` to include, `false` to skip.
83 +
84 + **Input:** `info` (sample metadata)
85 +
86 + ```rhai
87 + // Only export 44.1kHz samples
88 + info.sample_rate == 44100
89 + ```
90 +
91 + ```rhai
92 + // Skip samples longer than 30 seconds
93 + info.duration <= 30.0
94 + ```
95 +
96 + ### `transform_filename` — Rename files
97 +
98 + Called after the initial filename is generated. Return the new filename (stem only, no extension).
99 +
100 + **Input:** `name` (current filename string), `ctx` (export context)
101 +
102 + ```rhai
103 + // Uppercase with zero-padded index
104 + to_upper(name) + "_" + format_index(ctx.index, 3)
105 + // "kick" at index 5 → "KICK_005"
106 + ```
107 +
108 + ```rhai
109 + // Truncate and add device prefix
110 + let short = truncate(name, 8);
111 + "SP_" + to_upper(short)
112 + ```
113 +
114 + ### `pre_export` — Before batch
115 +
116 + Called once before the export batch starts. No return value.
117 +
118 + **Input:** `ctx` (export context)
119 +
120 + ### `post_export` — After batch
121 +
122 + Called once after all exports complete. No return value.
123 +
124 + **Input:** `ctx` (export context)
125 +
126 + ## Available Data
127 +
128 + ### Sample Info (`info`)
129 +
130 + Available in `validate_sample`:
131 +
132 + | Property | Type | Description |
133 + |----------|------|-------------|
134 + | `info.hash` | String | Content-addressed SHA-256 ID |
135 + | `info.name` | String | Original filename |
136 + | `info.extension` | String | File extension (e.g. "wav") |
137 + | `info.sample_rate` | Integer | Sample rate in Hz |
138 + | `info.bit_depth` | Integer | Bit depth (16, 24, etc.) |
139 + | `info.channels` | Integer | Channel count (1=mono, 2=stereo) |
140 + | `info.duration` | Float | Duration in seconds |
141 + | `info.file_size` | Integer | File size in bytes |
142 +
143 + ### Export Context (`ctx`)
144 +
145 + Available in `transform_filename`, `pre_export`, and `post_export`:
146 +
147 + | Property | Type | Description |
148 + |----------|------|-------------|
149 + | `ctx.device_name` | String | Device name from manifest |
150 + | `ctx.destination` | String | Export destination path |
151 + | `ctx.filename` | String | Current filename (stem) |
152 + | `ctx.extension` | String | File extension |
153 + | `ctx.index` | Integer | Current file index (0-based) |
154 + | `ctx.total` | Integer | Total files in batch |
155 +
156 + ## Host Functions
157 +
158 + String and format helpers available in all hooks:
159 +
160 + | Function | Example | Result |
161 + |----------|---------|--------|
162 + | `pad_left(s, width, fill)` | `pad_left("42", 5, "0")` | `"00042"` |
163 + | `pad_right(s, width, fill)` | `pad_right("hi", 5, " ")` | `"hi "` |
164 + | `truncate(s, max_len)` | `truncate("hello world", 5)` | `"hello"` |
165 + | `to_upper(s)` | `to_upper("kick")` | `"KICK"` |
166 + | `to_lower(s)` | `to_lower("KICK")` | `"kick"` |
167 + | `replace_char(s, from, to)` | `replace_char("a-b", "-", "_")` | `"a_b"` |
168 + | `strip_non_ascii(s)` | Removes non-ASCII characters | |
169 + | `format_index(index, width)` | `format_index(3, 3)` | `"003"` |
170 + | `file_stem(path)` | `file_stem("kick.wav")` | `"kick"` |
171 + | `file_extension(path)` | `file_extension("kick.wav")` | `"wav"` |
172 +
173 + No filesystem access, no network, no process spawning. Scripts can only manipulate strings and return values.
174 +
175 + ## Sandbox Limits
176 +
177 + | Limit | Value |
178 + |-------|-------|
179 + | Max operations per script call | 100,000 |
180 + | Max function call depth | 32 |
181 + | Max string length | 10,000 characters |
182 + | Max array size | 1,000 elements |
183 + | Max map size | 100 entries |
184 +
185 + Exceeding any limit terminates the script with an error. Infinite loops are caught by the operation limit.
186 +
187 + ## Example: Custom Device Profile
188 +
189 + A minimal profile for a sampler that only accepts 16-bit WAV at 44.1kHz with 8-character uppercase filenames:
190 +
191 + **`~/.config/audiofiles/plugins/user/my-sampler/manifest.toml`**
192 +
193 + ```toml
194 + [device]
195 + name = "My Sampler"
196 + manufacturer = "DIY"
197 + version = "1.0"
198 +
199 + [audio]
200 + formats = ["wav"]
201 + sample_rates = [44100]
202 + bit_depths = [16]
203 + channels = "mono"
204 +
205 + [naming]
206 + case = "upper"
207 + separator = "_"
208 + max_length = 8
209 + strip_special = true
210 + ```
211 +
212 + No hooks needed — the manifest alone constrains the export. After saving this file, restart audiofiles and "My Sampler" appears in the device profile dropdown.
213 +
214 + ## Example: Profile with Hooks
215 +
216 + Adding a validation hook that rejects stereo samples and a filename hook that zero-pads indices:
217 +
218 + **`manifest.toml`** (add to the above):
219 +
220 + ```toml
221 + [hooks]
222 + validate_sample = "hooks/validate.rhai"
223 + transform_filename = "hooks/transform.rhai"
224 + ```
225 +
226 + **`hooks/validate.rhai`**:
227 +
228 + ```rhai
229 + // Mono only, under 10 seconds, reasonable file size
230 + info.channels == 1 && info.duration < 10.0 && info.file_size < 5000000
231 + ```
232 +
233 + **`hooks/transform.rhai`**:
234 +
235 + ```rhai
236 + // PAD_000, PAD_001, etc.
237 + truncate(to_upper(name), 3) + "_" + format_index(ctx.index, 3)
238 + ```
239 +
240 + ## Feature Flag
241 +
242 + The plugin system is gated behind the `device-profiles` Cargo feature. When disabled, the device profile dropdown is empty and no Rhai code is compiled.
243 +
244 + ## See Also
245 +
246 + - Balanced Breakfast's [Plugin Authoring](../../balanced_breakfast/docs/plugin_authoring.md) covers the shared Rhai sandbox patterns in more detail (BB uses Rhai for feed source plugins with a different hook surface)
M docs/todo.md +4 -2
@@ -1,9 +1,11 @@
1 1 # audiofiles TODO
2 2
3 3 ## Status
4 - Done: All pre-beta phases (0-8 incl. system tray, UX Overhaul, 7B MIDI instrument, 9A-9F Cloud Sync, Collections). Two-layer ML classifier deployed. 610 tests. DB v11. All audit items resolved. Theme audit complete (17 themes bundled). Native drag-out implemented (macOS/Windows). VFS symlink mirror. OTA updater. Brand rename complete. Standalone-only (plugin/IPC/xtask removed). VP-tree indexes for similarity and fingerprint search. Tag tree sidebar (dot-separated tags as collapsible folders). Zero audit cold spots. Analysis engine overhauled: rayon parallel worker, 30s analysis cap, 16-class classifier (added Ambience/Impact/Foley/Texture), MFCC feature extraction, 35-feature vector. Two-layer ML classification: Layer 1 rule-based broad classifier (Drum/Bass/Vocal/Synth/etc.), Layer 2 Random Forest (200 trees, 7.4MB embedded model) for drum sub-classification. 94.4% strict / 95.6% lenient on 4343 labeled drum samples.
4 + Done: All pre-beta phases. Active: None. Next: Sample forge (phases 10-16).
5 5
6 - **Scope:** Pre-beta coding complete. ML classifier done. Sample forge features next.
6 + v0.3.0, standalone-only. Audit grade A. License key activation. Two-layer ML classifier. 17 bundled themes.
7 +
8 + **Scope:** Pre-beta coding complete. All remaining sections are post-beta.
7 9
8 10 Completed work archived in `docs/archive/af_todo_done.md`.
9 11