max / audiofiles
10 files changed,
+191 insertions,
-12 deletions
| @@ -93,7 +93,12 @@ impl DirectBackend { | |||
| 93 | 93 | fingerprint_index: Mutex::new(None), | |
| 94 | 94 | similarity_index: Mutex::new(None), | |
| 95 | 95 | #[cfg(feature = "device-profiles")] | |
| 96 | - | plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| { | |
| 96 | + | plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| { | |
| 97 | + | // Embedded (include_str!) profiles are compile-checked, so this | |
| 98 | + | // should never trip — but if it does, the conform/M8/MPC wedge | |
| 99 | + | // would silently show "No device profiles". Log so the empty | |
| 100 | + | // registry is diagnosable instead of mysterious. | |
| 101 | + | tracing::error!("Failed to load device-profile registry, falling back to empty: {e}"); | |
| 97 | 102 | audiofiles_rhai::registry::PluginRegistry::new() | |
| 98 | 103 | }), | |
| 99 | 104 | } |
| @@ -168,7 +168,7 @@ impl BrowserState { | |||
| 168 | 168 | // isn't filtering on a tag that no longer exists. | |
| 169 | 169 | self.search_filter.required_tags.retain(|t| t != old_tag); | |
| 170 | 170 | self.apply_search(); | |
| 171 | - | self.status = format!("Renamed tag: {old_tag} → {new_tag} ({count} sample{})", | |
| 171 | + | self.status = format!("Renamed tag: {old_tag} to {new_tag} ({count} sample{})", | |
| 172 | 172 | if count == 1 { "" } else { "s" }); | |
| 173 | 173 | } | |
| 174 | 174 | Err(e) => self.status = format!("Rename failed: {e}"), |
| @@ -165,15 +165,24 @@ fn draw_chop_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 165 | 165 | if ui.add_enabled(!disabled, egui::Button::new("Preview slices")).clicked() { | |
| 166 | 166 | state.forge_preview_slices(); | |
| 167 | 167 | } | |
| 168 | + | // AF-9: require a current preview before chopping so the user always | |
| 169 | + | // commits to a known slice count instead of an unknown number. Changing | |
| 170 | + | // any chop parameter clears the marks, which re-gates this button. | |
| 168 | 171 | let slice_count = state.forge.slice_marks.len().saturating_sub(1); | |
| 169 | - | let chop_label = if slice_count > 0 { | |
| 170 | - | format!("Chop into {slice_count} slices") | |
| 172 | + | let has_preview = slice_count > 0; | |
| 173 | + | let chop_label = if has_preview { | |
| 174 | + | let noun = if slice_count == 1 { "slice" } else { "slices" }; | |
| 175 | + | format!("Chop into {slice_count} {noun}") | |
| 171 | 176 | } else { | |
| 172 | 177 | "Chop".to_string() | |
| 173 | 178 | }; | |
| 174 | - | if ui.add_enabled(!disabled, egui::Button::new(chop_label)).clicked() { | |
| 179 | + | let chop = ui.add_enabled(!disabled && has_preview, egui::Button::new(chop_label)); | |
| 180 | + | if chop.clicked() { | |
| 175 | 181 | state.forge_apply_chop(); | |
| 176 | 182 | } | |
| 183 | + | if !has_preview { | |
| 184 | + | chop.on_hover_text("Preview the slices first to see how many will be created."); | |
| 185 | + | } | |
| 177 | 186 | }); | |
| 178 | 187 | ui.label( | |
| 179 | 188 | egui::RichText::new("Slices are written into a new folder beside this sample.") |
| @@ -713,7 +713,7 @@ fn draw_cap_picker( | |||
| 713 | 713 | BillingInterval::Annual => "year", | |
| 714 | 714 | }; | |
| 715 | 715 | ui.label(format!( | |
| 716 | - | "{} → {}/{}", | |
| 716 | + | "{} to {}/{}", | |
| 717 | 717 | format_cap(cap_bytes), | |
| 718 | 718 | format_cents(price_cents), | |
| 719 | 719 | interval_word, |
| @@ -103,7 +103,13 @@ pub fn tool_window<R>( | |||
| 103 | 103 | .collapsible(true) | |
| 104 | 104 | .default_width(default_width) | |
| 105 | 105 | .min_width(min_width) | |
| 106 | - | .show(ctx, |ui| add_contents(ui)) | |
| 106 | + | // Inner scroll so tool surfaces taller than the window (or a short | |
| 107 | + | // screen) stay fully reachable instead of clipping their lower sections. | |
| 108 | + | .show(ctx, |ui| { | |
| 109 | + | egui::ScrollArea::vertical() | |
| 110 | + | .show(ui, |ui| add_contents(ui)) | |
| 111 | + | .inner | |
| 112 | + | }) | |
| 107 | 113 | .and_then(|r| r.inner) | |
| 108 | 114 | } | |
| 109 | 115 |
| @@ -68,8 +68,15 @@ pub fn compute_slices( | |||
| 68 | 68 | starts | |
| 69 | 69 | } | |
| 70 | 70 | ChopMethod::EqualDivisions(n) => { | |
| 71 | - | let n = (*n).max(1); | |
| 72 | - | (0..n).map(|i| i * total_frames / n).collect() | |
| 71 | + | // Clamp to [1, total_frames]: more divisions than frames can't yield | |
| 72 | + | // more than `total_frames` distinct boundaries (extras dedup away), | |
| 73 | + | // and the cap bounds the iteration for adversarial n. `compute_slices` | |
| 74 | + | // is public and accepts arbitrary n. The u64 intermediate then keeps | |
| 75 | + | // `i * total_frames` from overflowing usize on 32-bit / multi-GB bufs. | |
| 76 | + | let n = (*n).clamp(1, total_frames); | |
| 77 | + | (0..n) | |
| 78 | + | .map(|i| ((i as u64 * total_frames as u64) / n as u64) as usize) | |
| 79 | + | .collect() | |
| 73 | 80 | } | |
| 74 | 81 | ChopMethod::BpmGrid { | |
| 75 | 82 | bpm, | |
| @@ -291,6 +298,31 @@ mod tests { | |||
| 291 | 298 | } | |
| 292 | 299 | ||
| 293 | 300 | #[test] | |
| 301 | + | fn equal_divisions_non_multiple_has_trailing_slice() { | |
| 302 | + | // 10 mono frames into 3: 0,3,6 -> [0,3),[3,6),[6,10). Last slice carries | |
| 303 | + | // the remainder; tiling still covers the whole buffer with no gap. | |
| 304 | + | let samples = vec![0.0f32; 10]; | |
| 305 | + | let slices = compute_slices(&samples, 1, 44100, &ChopMethod::EqualDivisions(3)).unwrap(); | |
| 306 | + | assert_eq!(slices.len(), 3); | |
| 307 | + | assert_eq!(slices[0], Slice { start_frame: 0, end_frame: 3 }); | |
| 308 | + | assert_eq!(slices[1], Slice { start_frame: 3, end_frame: 6 }); | |
| 309 | + | assert_eq!(slices[2], Slice { start_frame: 6, end_frame: 10 }); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn equal_divisions_huge_n_is_clamped_not_pathological() { | |
| 314 | + | // AF-5: an adversarial n must neither overflow `i * total_frames` (u64 | |
| 315 | + | // intermediate) nor iterate billions of times (n is clamped to | |
| 316 | + | // total_frames). 1000-frame buffer -> at most 1000 one-frame slices, | |
| 317 | + | // returning promptly. | |
| 318 | + | let samples = vec![0.0f32; 1000]; | |
| 319 | + | let slices = compute_slices(&samples, 1, 44100, &ChopMethod::EqualDivisions(usize::MAX)).unwrap(); | |
| 320 | + | assert_eq!(slices.len(), 1000); | |
| 321 | + | assert_eq!(slices.first().unwrap().start_frame, 0); | |
| 322 | + | assert_eq!(slices.last().unwrap().end_frame, 1000); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 294 | 326 | fn bpm_grid_step_matches_tempo() { | |
| 295 | 327 | // 120 BPM at 48k = 0.5s/beat = 24000 frames/beat. | |
| 296 | 328 | let sample_rate = 48000; |
| @@ -127,6 +127,7 @@ pub fn import_chop( | |||
| 127 | 127 | let dir_name = format!("{}_slices", staging.stem); | |
| 128 | 128 | let dir_node = vfs::create_directory(db, vfs_id, parent_id, &dir_name)?; | |
| 129 | 129 | ||
| 130 | + | let run_dir = staging.slices.first().and_then(|(_, p)| p.parent().map(Path::to_path_buf)); | |
| 130 | 131 | let mut written = 0usize; | |
| 131 | 132 | for (slice_name, temp_path) in &staging.slices { | |
| 132 | 133 | let import = store.import(temp_path, db); | |
| @@ -135,6 +136,7 @@ pub fn import_chop( | |||
| 135 | 136 | vfs::create_sample_link(db, vfs_id, Some(dir_node), slice_name, &hash)?; | |
| 136 | 137 | written += 1; | |
| 137 | 138 | } | |
| 139 | + | cleanup_run_dir(run_dir.as_deref()); | |
| 138 | 140 | ||
| 139 | 141 | Ok(ChopResult { dir_node, slice_count: written }) | |
| 140 | 142 | } | |
| @@ -212,10 +214,12 @@ pub fn import_conform( | |||
| 212 | 214 | parent_id: Option<NodeId>, | |
| 213 | 215 | staging: ConformStaging, | |
| 214 | 216 | ) -> Result<ConformResult, CoreError> { | |
| 217 | + | let run_dir = staging.temp_path.parent().map(Path::to_path_buf); | |
| 215 | 218 | let import = store.import(&staging.temp_path, db); | |
| 216 | 219 | let _ = std::fs::remove_file(&staging.temp_path); | |
| 217 | 220 | let hash = import?; | |
| 218 | 221 | vfs::create_sample_link(db, vfs_id, parent_id, &staging.out_name, &hash)?; | |
| 222 | + | cleanup_run_dir(run_dir.as_deref()); | |
| 219 | 223 | Ok(ConformResult { hash, overshoot: staging.overshoot }) | |
| 220 | 224 | } | |
| 221 | 225 | ||
| @@ -268,12 +272,31 @@ fn format_rate_tag(rate: u32) -> String { | |||
| 268 | 272 | } | |
| 269 | 273 | ||
| 270 | 274 | /// Per-process temp dir for forge intermediates; created on demand. | |
| 275 | + | /// Monotonic per-process counter distinguishing forge runs in this process; | |
| 276 | + | /// combined with the pid it uniquifies the temp run dir without an extra dep. | |
| 277 | + | static FORGE_RUN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | |
| 278 | + | ||
| 271 | 279 | fn forge_temp_dir() -> Result<PathBuf, CoreError> { | |
| 272 | - | let dir = std::env::temp_dir().join("audiofiles_forge"); | |
| 280 | + | let parent = std::env::temp_dir().join("audiofiles_forge"); | |
| 281 | + | std::fs::create_dir_all(&parent).map_err(|e| io_err(&parent, e))?; | |
| 282 | + | // Unique per-run subdir. Forge temp filenames are derived from the source | |
| 283 | + | // stem, so two concurrent or retried runs on identically-named sources would | |
| 284 | + | // otherwise collide on the shared path. pid separates processes; the counter | |
| 285 | + | // separates runs within one. The import stage removes the dir once empty. | |
| 286 | + | let seq = FORGE_RUN_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | |
| 287 | + | let dir = parent.join(format!("run_{}_{}", std::process::id(), seq)); | |
| 273 | 288 | std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?; | |
| 274 | 289 | Ok(dir) | |
| 275 | 290 | } | |
| 276 | 291 | ||
| 292 | + | /// Best-effort removal of a now-empty forge run dir after its temp files have | |
| 293 | + | /// been imported and deleted. Silently ignores a non-empty/missing dir. | |
| 294 | + | fn cleanup_run_dir(dir: Option<&Path>) { | |
| 295 | + | if let Some(dir) = dir { | |
| 296 | + | let _ = std::fs::remove_dir(dir); | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 277 | 300 | #[cfg(test)] | |
| 278 | 301 | mod tests { | |
| 279 | 302 | use super::*; |
| @@ -157,7 +157,7 @@ impl SampleStore { | |||
| 157 | 157 | Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path), | |
| 158 | 158 | Err(e) => return Err(CoreError::Db(e)), | |
| 159 | 159 | }; | |
| 160 | - | let original_name = crate::util::get_filename(path, "unknown"); | |
| 160 | + | let original_name = clamp_original_name(crate::util::get_filename(path, "unknown")); | |
| 161 | 161 | ||
| 162 | 162 | // Probe duration from file headers (cheap, no full decode) | |
| 163 | 163 | let duration = probe_duration(path); | |
| @@ -351,6 +351,22 @@ pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> { | |||
| 351 | 351 | query_sample_field(db, hash, "original_name") | |
| 352 | 352 | } | |
| 353 | 353 | ||
| 354 | + | /// Clamp an imported file's display name before it enters the DB and the sync | |
| 355 | + | /// payload. The name is taken verbatim from disk, so bound its length (on a | |
| 356 | + | /// UTF-8 char boundary) to a filesystem-scale limit. SQL binds it as a | |
| 357 | + | /// parameter, so this is storage hygiene, not an injection guard. | |
| 358 | + | fn clamp_original_name(name: String) -> String { | |
| 359 | + | const MAX_BYTES: usize = 255; | |
| 360 | + | if name.len() <= MAX_BYTES { | |
| 361 | + | return name; | |
| 362 | + | } | |
| 363 | + | let mut end = MAX_BYTES; | |
| 364 | + | while end > 0 && !name.is_char_boundary(end) { | |
| 365 | + | end -= 1; | |
| 366 | + | } | |
| 367 | + | name[..end].to_string() | |
| 368 | + | } | |
| 369 | + | ||
| 354 | 370 | // --- Loose-files mode --- | |
| 355 | 371 | ||
| 356 | 372 | /// Look up the source_path for a sample (loose-files mode imports only). | |
| @@ -648,7 +664,7 @@ impl SampleStore { | |||
| 648 | 664 | let hash = format!("{:x}", hasher.finalize()); | |
| 649 | 665 | ||
| 650 | 666 | let ext = crate::util::get_extension(path); | |
| 651 | - | let original_name = crate::util::get_filename(path, "unknown"); | |
| 667 | + | let original_name = clamp_original_name(crate::util::get_filename(path, "unknown")); | |
| 652 | 668 | ||
| 653 | 669 | // Probe duration from file headers (cheap, no full decode) | |
| 654 | 670 | let duration = probe_duration(path); | |
| @@ -694,6 +710,22 @@ mod tests { | |||
| 694 | 710 | } | |
| 695 | 711 | ||
| 696 | 712 | #[test] | |
| 713 | + | fn clamp_original_name_caps_length_on_char_boundary() { | |
| 714 | + | // Short names pass through untouched. | |
| 715 | + | assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav"); | |
| 716 | + | ||
| 717 | + | // Over-long ASCII is capped to 255 bytes. | |
| 718 | + | let long = "a".repeat(1000); | |
| 719 | + | assert_eq!(clamp_original_name(long).len(), 255); | |
| 720 | + | ||
| 721 | + | // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8). | |
| 722 | + | let multibyte = "é".repeat(200); // 400 bytes | |
| 723 | + | let clamped = clamp_original_name(multibyte); | |
| 724 | + | assert!(clamped.len() <= 255); | |
| 725 | + | assert!(std::str::from_utf8(clamped.as_bytes()).is_ok()); | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | #[test] | |
| 697 | 729 | fn import_creates_file_and_row() { | |
| 698 | 730 | let (dir, db, store) = setup(); | |
| 699 | 731 | let src = create_test_file(&dir, "kick.wav", b"fake audio data"); |
| @@ -1,10 +1,44 @@ | |||
| 1 | 1 | //! Rhai engine setup: sandboxed engine with registered types and host API. | |
| 2 | 2 | ||
| 3 | + | use std::cell::Cell; | |
| 4 | + | use std::time::{Duration, Instant}; | |
| 5 | + | ||
| 3 | 6 | use rhai::Engine; | |
| 4 | 7 | ||
| 5 | 8 | use crate::host_api; | |
| 6 | 9 | use crate::types::{self, RhaiExportContext, RhaiSampleInfo}; | |
| 7 | 10 | ||
| 11 | + | /// Wall-clock ceiling for a single script run. `set_max_operations` already | |
| 12 | + | /// bounds interpreter ops, but this also caps total elapsed time so a script | |
| 13 | + | /// that spends a long time across many operations can't hang the rename/import | |
| 14 | + | /// thread. | |
| 15 | + | const SCRIPT_WALL_CLOCK_LIMIT: Duration = Duration::from_secs(5); | |
| 16 | + | ||
| 17 | + | thread_local! { | |
| 18 | + | // Deadline for the script currently running on this thread, if any. The | |
| 19 | + | // engine is shared and reused across hook calls, so timing is armed per-run | |
| 20 | + | // (not at engine creation) via [`arm_script_deadline`]. | |
| 21 | + | static SCRIPT_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// RAII guard that disarms the per-thread script deadline on drop. | |
| 25 | + | #[must_use = "the deadline is only active while the guard is alive"] | |
| 26 | + | pub struct ScriptDeadlineGuard(()); | |
| 27 | + | ||
| 28 | + | impl Drop for ScriptDeadlineGuard { | |
| 29 | + | fn drop(&mut self) { | |
| 30 | + | SCRIPT_DEADLINE.with(|d| d.set(None)); | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Arm the wall-clock deadline for the next script eval on this thread. Hold the | |
| 35 | + | /// returned guard for the duration of the eval; the engine's `on_progress` hook | |
| 36 | + | /// aborts evaluation once the deadline passes. | |
| 37 | + | pub fn arm_script_deadline() -> ScriptDeadlineGuard { | |
| 38 | + | SCRIPT_DEADLINE.with(|d| d.set(Some(Instant::now() + SCRIPT_WALL_CLOCK_LIMIT))); | |
| 39 | + | ScriptDeadlineGuard(()) | |
| 40 | + | } | |
| 41 | + | ||
| 8 | 42 | /// Create a sandboxed Rhai engine with all custom types and host functions registered. | |
| 9 | 43 | pub fn create_engine() -> Engine { | |
| 10 | 44 | let mut engine = Engine::new(); | |
| @@ -17,6 +51,16 @@ pub fn create_engine() -> Engine { | |||
| 17 | 51 | engine.set_max_map_size(100); | |
| 18 | 52 | engine.set_max_expr_depths(64, 32); | |
| 19 | 53 | ||
| 54 | + | // Wall-clock watchdog: abort once the per-run deadline (if armed) passes. | |
| 55 | + | // Fires between operations, so it bounds total script time on top of the | |
| 56 | + | // operation-count cap. No-op when no deadline is armed. | |
| 57 | + | engine.on_progress(|_ops| { | |
| 58 | + | let expired = SCRIPT_DEADLINE | |
| 59 | + | .with(|d| d.get()) | |
| 60 | + | .is_some_and(|deadline| Instant::now() >= deadline); | |
| 61 | + | expired.then(|| "script exceeded its wall-clock time limit".into()) | |
| 62 | + | }); | |
| 63 | + | ||
| 20 | 64 | // Suppress print/debug output from scripts | |
| 21 | 65 | engine.on_print(|_| {}); | |
| 22 | 66 | engine.on_debug(|_, _, _| {}); | |
| @@ -57,6 +101,30 @@ mod tests { | |||
| 57 | 101 | } | |
| 58 | 102 | ||
| 59 | 103 | #[test] | |
| 104 | + | fn engine_aborts_on_wall_clock_deadline() { | |
| 105 | + | // AF-3: arm an already-expired deadline so the first on_progress check | |
| 106 | + | // trips. The error must be ErrorTerminated (the wall-clock watchdog), | |
| 107 | + | // distinguishing it from the operation-count limit (ErrorTooManyOperations). | |
| 108 | + | let engine = create_engine(); | |
| 109 | + | SCRIPT_DEADLINE.with(|d| d.set(Some(Instant::now() - Duration::from_secs(1)))); | |
| 110 | + | let err = engine.eval::<()>("let x = 0; loop { x += 1; }").unwrap_err(); | |
| 111 | + | SCRIPT_DEADLINE.with(|d| d.set(None)); | |
| 112 | + | assert!( | |
| 113 | + | matches!(*err, rhai::EvalAltResult::ErrorTerminated(..)), | |
| 114 | + | "expected wall-clock termination, got {err:?}" | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[test] | |
| 119 | + | fn arm_script_deadline_guard_disarms_on_drop() { | |
| 120 | + | { | |
| 121 | + | let _guard = arm_script_deadline(); | |
| 122 | + | assert!(SCRIPT_DEADLINE.with(|d| d.get()).is_some(), "armed while guard alive"); | |
| 123 | + | } | |
| 124 | + | assert!(SCRIPT_DEADLINE.with(|d| d.get()).is_none(), "disarmed after guard drop"); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[test] | |
| 60 | 128 | fn engine_sample_info_getters_work() { | |
| 61 | 129 | let engine = create_engine(); | |
| 62 | 130 | let mut scope = rhai::Scope::new(); |
| @@ -45,6 +45,7 @@ pub fn run_validate_sample( | |||
| 45 | 45 | let mut scope = Scope::new(); | |
| 46 | 46 | scope.push("info", info); | |
| 47 | 47 | ||
| 48 | + | let _deadline = crate::engine::arm_script_deadline(); | |
| 48 | 49 | engine | |
| 49 | 50 | .eval_ast_with_scope::<bool>(&mut scope, ast) | |
| 50 | 51 | .map_err(|e| PluginError::ScriptRuntime(e.to_string())) | |
| @@ -62,6 +63,7 @@ pub fn run_transform_filename( | |||
| 62 | 63 | scope.push("name", name); | |
| 63 | 64 | scope.push("ctx", ctx); | |
| 64 | 65 | ||
| 66 | + | let _deadline = crate::engine::arm_script_deadline(); | |
| 65 | 67 | engine | |
| 66 | 68 | .eval_ast_with_scope::<String>(&mut scope, ast) | |
| 67 | 69 | .map_err(|e| PluginError::ScriptRuntime(e.to_string())) | |
| @@ -77,6 +79,7 @@ pub fn run_pre_export( | |||
| 77 | 79 | let mut scope = Scope::new(); | |
| 78 | 80 | scope.push("ctx", ctx); | |
| 79 | 81 | ||
| 82 | + | let _deadline = crate::engine::arm_script_deadline(); | |
| 80 | 83 | let _ = engine | |
| 81 | 84 | .eval_ast_with_scope::<rhai::Dynamic>(&mut scope, ast) | |
| 82 | 85 | .map_err(|e| PluginError::ScriptRuntime(e.to_string()))?; | |
| @@ -93,6 +96,7 @@ pub fn run_post_export( | |||
| 93 | 96 | let mut scope = Scope::new(); | |
| 94 | 97 | scope.push("ctx", ctx); | |
| 95 | 98 | ||
| 99 | + | let _deadline = crate::engine::arm_script_deadline(); | |
| 96 | 100 | let _ = engine | |
| 97 | 101 | .eval_ast_with_scope::<rhai::Dynamic>(&mut scope, ast) | |
| 98 | 102 | .map_err(|e| PluginError::ScriptRuntime(e.to_string()))?; |