max / audiofiles
12 files changed,
+1548 insertions,
-296 deletions
| @@ -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) |
| @@ -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(¬es); | |
| 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(¬es); | |
| 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) |
| @@ -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 |