max / audiofiles
8 files changed,
+150 insertions,
-51 deletions
| @@ -981,29 +981,29 @@ impl Backend for DirectBackend { | |||
| 981 | 981 | // --- Sample Forge --- | |
| 982 | 982 | ||
| 983 | 983 | #[instrument(skip_all)] | |
| 984 | - | fn compute_chop_preview( | |
| 985 | - | &self, | |
| 986 | - | hash: &str, | |
| 987 | - | ext: &str, | |
| 988 | - | method: &ChopMethod, | |
| 989 | - | ) -> BackendResult<Vec<f32>> { | |
| 990 | - | let db = self.db.lock(); | |
| 991 | - | let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; | |
| 992 | - | let decoded = audiofiles_core::export::decode::decode_multichannel(&path)?; | |
| 993 | - | let total_frames = (decoded.samples.len() / decoded.channels.max(1) as usize).max(1); | |
| 994 | - | let slices = audiofiles_core::forge::compute_slices( | |
| 995 | - | &decoded.samples, | |
| 996 | - | decoded.channels, | |
| 997 | - | decoded.sample_rate, | |
| 998 | - | method, | |
| 999 | - | )?; | |
| 1000 | - | // Boundary fractions: each slice's start, plus the final end (1.0). | |
| 1001 | - | let mut marks: Vec<f32> = slices | |
| 1002 | - | .iter() | |
| 1003 | - | .map(|s| s.start_frame as f32 / total_frames as f32) | |
| 1004 | - | .collect(); | |
| 1005 | - | marks.push(1.0); | |
| 1006 | - | Ok(marks) | |
| 984 | + | fn start_chop_preview(&self, hash: &str, ext: &str, method: &ChopMethod) -> BackendResult<()> { | |
| 985 | + | // Resolve the source path under a brief lock; the decode + slice runs on | |
| 986 | + | // the forge worker and the marks arrive via ForgeEvent::PreviewComplete. | |
| 987 | + | let path = { | |
| 988 | + | let db = self.db.lock(); | |
| 989 | + | audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? | |
| 990 | + | }; | |
| 991 | + | // Reuse the forge worker if present (preview queues behind any running | |
| 992 | + | // chop/conform); spawn one lazily otherwise. Preview is read-only and | |
| 993 | + | // sets no forge_pending, so it never interferes with an import. | |
| 994 | + | let mut guard = self.forge_worker.lock(); | |
| 995 | + | if guard.is_none() { | |
| 996 | + | let handle = audiofiles_core::forge::spawn_forge_worker() | |
| 997 | + | .map_err(|e| BackendError::Other(format!("failed to spawn forge worker: {e}")))?; | |
| 998 | + | *guard = Some(handle); | |
| 999 | + | } | |
| 1000 | + | if let Some(w) = guard.as_ref() { | |
| 1001 | + | w.send(ForgeCommand::Preview { | |
| 1002 | + | source_path: path, | |
| 1003 | + | method: method.clone(), | |
| 1004 | + | }); | |
| 1005 | + | } | |
| 1006 | + | Ok(()) | |
| 1007 | 1007 | } | |
| 1008 | 1008 | ||
| 1009 | 1009 | #[instrument(skip_all)] | |
| @@ -1684,6 +1684,9 @@ impl Backend for DirectBackend { | |||
| 1684 | 1684 | let _ = std::fs::remove_file(&staging.temp_path); | |
| 1685 | 1685 | } | |
| 1686 | 1686 | } | |
| 1687 | + | ForgeEvent::PreviewComplete { marks } => { | |
| 1688 | + | events.push(BackendEvent::ChopPreviewComplete { marks }); | |
| 1689 | + | } | |
| 1687 | 1690 | } | |
| 1688 | 1691 | } | |
| 1689 | 1692 | ||
| @@ -1903,10 +1906,23 @@ mod tests { | |||
| 1903 | 1906 | write_float_wav(&src, 1, 44100, &samples); | |
| 1904 | 1907 | let hash = backend.import_file(&src).unwrap(); | |
| 1905 | 1908 | ||
| 1906 | - | // Preview returns slice boundaries (N starts + trailing 1.0). | |
| 1907 | - | let marks = backend | |
| 1908 | - | .compute_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4)) | |
| 1909 | + | // Preview computes slice boundaries on the forge worker (N starts + | |
| 1910 | + | // trailing 1.0), delivered via poll_events. | |
| 1911 | + | backend | |
| 1912 | + | .start_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4)) | |
| 1909 | 1913 | .unwrap(); | |
| 1914 | + | let marks = loop { | |
| 1915 | + | let mut found = None; | |
| 1916 | + | for ev in backend.poll_events() { | |
| 1917 | + | if let BackendEvent::ChopPreviewComplete { marks } = ev { | |
| 1918 | + | found = Some(marks); | |
| 1919 | + | } | |
| 1920 | + | } | |
| 1921 | + | if let Some(m) = found { | |
| 1922 | + | break m; | |
| 1923 | + | } | |
| 1924 | + | std::thread::yield_now(); | |
| 1925 | + | }; | |
| 1910 | 1926 | assert_eq!(marks.len(), 5); | |
| 1911 | 1927 | assert_eq!(marks.last().copied(), Some(1.0)); | |
| 1912 | 1928 |
| @@ -134,6 +134,10 @@ pub enum BackendEvent { | |||
| 134 | 134 | bit_depth: u16, | |
| 135 | 135 | overshoot: Option<ForgeOvershoot>, | |
| 136 | 136 | }, | |
| 137 | + | /// Chop preview marks (slice-boundary fractions) ready for the overlay. | |
| 138 | + | ChopPreviewComplete { | |
| 139 | + | marks: Vec<f32>, | |
| 140 | + | }, | |
| 137 | 141 | ForgeError { | |
| 138 | 142 | error: String, | |
| 139 | 143 | }, | |
| @@ -621,14 +625,11 @@ pub trait Backend: Send + Sync { | |||
| 621 | 625 | ||
| 622 | 626 | // --- Sample Forge --- | |
| 623 | 627 | ||
| 624 | - | /// Compute slice-boundary positions (normalized 0..1 fractions of total | |
| 625 | - | /// length) for the given chop method, for waveform overlay preview. | |
| 626 | - | fn compute_chop_preview( | |
| 627 | - | &self, | |
| 628 | - | hash: &str, | |
| 629 | - | ext: &str, | |
| 630 | - | method: &ChopMethod, | |
| 631 | - | ) -> BackendResult<Vec<f32>>; | |
| 628 | + | /// Start computing slice-boundary positions (normalized 0..1 fractions of | |
| 629 | + | /// total length) for the given chop method, for the waveform overlay. The | |
| 630 | + | /// decode + slice runs on the forge worker; the marks arrive via | |
| 631 | + | /// [`BackendEvent::ChopPreviewComplete`] (or `ForgeError`) in `poll_events`. | |
| 632 | + | fn start_chop_preview(&self, hash: &str, ext: &str, method: &ChopMethod) -> BackendResult<()>; | |
| 632 | 633 | ||
| 633 | 634 | /// Chop a sample into slices written as new samples in a `"{name}_slices"` | |
| 634 | 635 | /// directory under `parent_id`. Returns the number of slices created. |
| @@ -1,12 +1,10 @@ | |||
| 1 | 1 | //! Sample Forge state: open/close the forge window and drive chop, conform, and | |
| 2 | 2 | //! batch operations through the backend. | |
| 3 | 3 | //! | |
| 4 | - | //! Note: chop/conform/preview currently run synchronously on the GUI thread. | |
| 5 | - | //! This is fine for the common case (chopping loops and one-shots is fast), but | |
| 6 | - | //! conforming or chopping a long multichannel file can briefly stall the UI. | |
| 7 | - | //! Moving this work onto a worker thread (mirroring the import/analysis/export | |
| 8 | - | //! workers) is a tracked follow-up; see the audiofiles todo. The `busy` flag is | |
| 9 | - | //! in place for when that lands. | |
| 4 | + | //! Chop, conform, and chop-preview all run on the forge worker thread: the | |
| 5 | + | //! decode/slice/encode CPU work happens off the GUI thread and the result lands | |
| 6 | + | //! via a `BackendEvent` in `poll_workers`, so a long multichannel file no longer | |
| 7 | + | //! stalls the UI. The `busy` flag guards the in-flight window. | |
| 10 | 8 | ||
| 11 | 9 | use audiofiles_core::forge::ChopMethod; | |
| 12 | 10 | ||
| @@ -74,17 +72,17 @@ impl BrowserState { | |||
| 74 | 72 | } | |
| 75 | 73 | } | |
| 76 | 74 | ||
| 77 | - | /// Compute slice-boundary markers for the current method (overlay preview). | |
| 75 | + | /// Start computing slice-boundary markers for the current method (overlay | |
| 76 | + | /// preview). The decode + slice runs on the forge worker; the marks arrive | |
| 77 | + | /// via `BackendEvent::ChopPreviewComplete` and are applied in `poll_workers`. | |
| 78 | 78 | pub fn forge_preview_slices(&mut self) { | |
| 79 | 79 | let Some(hash) = self.forge.hash.clone() else { return }; | |
| 80 | 80 | let ext = self.forge.ext.clone(); | |
| 81 | 81 | let method = self.forge_chop_method(); | |
| 82 | - | match self.backend.compute_chop_preview(&hash, &ext, &method) { | |
| 83 | - | Ok(marks) => { | |
| 84 | - | // Slice count = boundaries minus the trailing 1.0 end marker. | |
| 85 | - | let count = marks.len().saturating_sub(1); | |
| 86 | - | self.forge.slice_marks = marks; | |
| 87 | - | self.status = format!("Preview: {count} slices"); | |
| 82 | + | match self.backend.start_chop_preview(&hash, &ext, &method) { | |
| 83 | + | Ok(()) => { | |
| 84 | + | self.forge.busy = true; | |
| 85 | + | self.status = "Computing chop preview...".to_string(); | |
| 88 | 86 | } | |
| 89 | 87 | Err(e) => { | |
| 90 | 88 | self.forge.slice_marks.clear(); |
| @@ -759,6 +759,13 @@ impl BrowserState { | |||
| 759 | 759 | self.status = msg; | |
| 760 | 760 | self.refresh_contents(); | |
| 761 | 761 | } | |
| 762 | + | BackendEvent::ChopPreviewComplete { marks } => { | |
| 763 | + | self.forge.busy = false; | |
| 764 | + | // Slice count = boundaries minus the trailing 1.0 end marker. | |
| 765 | + | let count = marks.len().saturating_sub(1); | |
| 766 | + | self.forge.slice_marks = marks; | |
| 767 | + | self.status = format!("Preview: {count} slices"); | |
| 768 | + | } | |
| 762 | 769 | BackendEvent::ForgeError { error } => { | |
| 763 | 770 | self.forge.busy = false; | |
| 764 | 771 | self.status = format!("Forge failed: {error}"); |
| @@ -27,8 +27,13 @@ pub struct DecodedMultichannel { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | /// Decode an audio file preserving its original channel layout. | |
| 30 | + | /// | |
| 31 | + | /// `pub(crate)`: full-file decode is reachable only from core worker/runner code | |
| 32 | + | /// (edit/forge/export workers, [`crate::forge::compute_preview_marks`]), never | |
| 33 | + | /// from the browser UI, so a synchronous decode can't creep back onto the egui | |
| 34 | + | /// frame thread (ultra-fuzz Chronic #1). | |
| 30 | 35 | #[instrument(skip_all)] | |
| 31 | - | pub fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError> { | |
| 36 | + | pub(crate) fn decode_multichannel(path: &Path) -> Result<DecodedMultichannel, CoreError> { | |
| 32 | 37 | let file = std::fs::File::open(path).map_err(|e| io_err(path, e))?; | |
| 33 | 38 | let mss = MediaSourceStream::new(Box::new(file), Default::default()); | |
| 34 | 39 |
| @@ -29,7 +29,7 @@ pub use conform::{ | |||
| 29 | 29 | OvershootReport, | |
| 30 | 30 | }; | |
| 31 | 31 | pub use runner::{ | |
| 32 | - | chop_to_temp, chop_to_vfs, conform_to_temp, conform_to_vfs, import_chop, import_conform, | |
| 33 | - | ChopResult, ChopStaging, ConformResult, ConformStaging, | |
| 32 | + | chop_to_temp, chop_to_vfs, compute_preview_marks, conform_to_temp, conform_to_vfs, import_chop, | |
| 33 | + | import_conform, ChopResult, ChopStaging, ConformResult, ConformStaging, | |
| 34 | 34 | }; | |
| 35 | 35 | pub use worker::{spawn_forge_worker, ForgeCommand, ForgeEvent, ForgeWorkerHandle}; |
| @@ -64,6 +64,25 @@ pub struct ConformStaging { | |||
| 64 | 64 | pub overshoot: Option<OvershootReport>, | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | /// Compute chop slice-boundary fractions (each in `0.0..=1.0`) for the preview | |
| 68 | + | /// overlay, without writing anything. Decodes `source_path` and runs `method`; | |
| 69 | + | /// no store/DB access, so it is safe to run on a worker thread. The final mark | |
| 70 | + | /// is always `1.0` (the end of the file). | |
| 71 | + | pub fn compute_preview_marks( | |
| 72 | + | source_path: &Path, | |
| 73 | + | method: &ChopMethod, | |
| 74 | + | ) -> Result<Vec<f32>, CoreError> { | |
| 75 | + | let decoded = decode_multichannel(source_path)?; | |
| 76 | + | let total_frames = (decoded.samples.len() / decoded.channels.max(1) as usize).max(1); | |
| 77 | + | let slices = compute_slices(&decoded.samples, decoded.channels, decoded.sample_rate, method)?; | |
| 78 | + | let mut marks: Vec<f32> = slices | |
| 79 | + | .iter() | |
| 80 | + | .map(|s| s.start_frame as f32 / total_frames as f32) | |
| 81 | + | .collect(); | |
| 82 | + | marks.push(1.0); | |
| 83 | + | Ok(marks) | |
| 84 | + | } | |
| 85 | + | ||
| 67 | 86 | /// CPU stage of chop: decode `source_path`, slice it with `method`, and encode | |
| 68 | 87 | /// each non-empty slice to a temp WAV. No store/DB access — safe to run on a | |
| 69 | 88 | /// worker thread. The import stage is [`import_chop`]. | |
| @@ -329,6 +348,23 @@ mod tests { | |||
| 329 | 348 | } | |
| 330 | 349 | ||
| 331 | 350 | #[test] | |
| 351 | + | fn preview_marks_tile_unit_interval() { | |
| 352 | + | let dir = tempfile::tempdir().unwrap(); | |
| 353 | + | let samples: Vec<f32> = (0..1000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect(); | |
| 354 | + | let src = dir.path().join("loop.wav"); | |
| 355 | + | write_test_wav(&src, 1, 44100, &samples); | |
| 356 | + | ||
| 357 | + | let marks = compute_preview_marks(&src, &ChopMethod::EqualDivisions(4)).unwrap(); | |
| 358 | + | // Four slices => four starts + the trailing 1.0 end marker. | |
| 359 | + | assert_eq!(marks.len(), 5); | |
| 360 | + | assert_eq!(marks.first().copied(), Some(0.0)); | |
| 361 | + | assert_eq!(marks.last().copied(), Some(1.0)); | |
| 362 | + | // Monotonic non-decreasing boundaries within [0, 1]. | |
| 363 | + | assert!(marks.windows(2).all(|w| w[0] <= w[1])); | |
| 364 | + | assert!(marks.iter().all(|&m| (0.0..=1.0).contains(&m))); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[test] | |
| 332 | 368 | fn chop_creates_slice_dir_and_samples() { | |
| 333 | 369 | let dir = tempfile::tempdir().unwrap(); | |
| 334 | 370 | let db = Database::open_in_memory().unwrap(); |
| @@ -15,7 +15,7 @@ use tracing::instrument; | |||
| 15 | 15 | ||
| 16 | 16 | use super::chop::ChopMethod; | |
| 17 | 17 | use super::conform::ConformTarget; | |
| 18 | - | use super::runner::{chop_to_temp, conform_to_temp, ChopStaging, ConformStaging}; | |
| 18 | + | use super::runner::{chop_to_temp, compute_preview_marks, conform_to_temp, ChopStaging, ConformStaging}; | |
| 19 | 19 | ||
| 20 | 20 | /// Command sent from the GUI thread to the forge worker. | |
| 21 | 21 | pub enum ForgeCommand { | |
| @@ -32,6 +32,12 @@ pub enum ForgeCommand { | |||
| 32 | 32 | target: ConformTarget, | |
| 33 | 33 | auto_trim: bool, | |
| 34 | 34 | }, | |
| 35 | + | /// Compute chop slice-boundary marks for the preview overlay (read-only: | |
| 36 | + | /// decodes + slices, writes nothing). Replies with [`ForgeEvent::PreviewComplete`]. | |
| 37 | + | Preview { | |
| 38 | + | source_path: PathBuf, | |
| 39 | + | method: ChopMethod, | |
| 40 | + | }, | |
| 35 | 41 | /// Cancel the current operation. | |
| 36 | 42 | Cancel, | |
| 37 | 43 | /// Shut down the worker thread. | |
| @@ -46,6 +52,8 @@ pub enum ForgeEvent { | |||
| 46 | 52 | ChopComplete { staging: ChopStaging }, | |
| 47 | 53 | /// Conform CPU work finished; the conformed file is staged awaiting import. | |
| 48 | 54 | ConformComplete { staging: ConformStaging }, | |
| 55 | + | /// Chop preview finished: slice-boundary fractions for the overlay. | |
| 56 | + | PreviewComplete { marks: Vec<f32> }, | |
| 49 | 57 | /// A forge operation failed. | |
| 50 | 58 | Error { error: String }, | |
| 51 | 59 | } | |
| @@ -187,6 +195,34 @@ fn forge_worker_loop( | |||
| 187 | 195 | } | |
| 188 | 196 | } | |
| 189 | 197 | } | |
| 198 | + | ForgeCommand::Preview { | |
| 199 | + | source_path, | |
| 200 | + | method, | |
| 201 | + | } => { | |
| 202 | + | cancel_flag.store(false, Ordering::Release); | |
| 203 | + | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 204 | + | compute_preview_marks(&source_path, &method) | |
| 205 | + | })); | |
| 206 | + | // A newer preview/op supersedes this one — drop the stale marks. | |
| 207 | + | if cancel_flag.load(Ordering::Acquire) { | |
| 208 | + | continue; | |
| 209 | + | } | |
| 210 | + | match outcome { | |
| 211 | + | Ok(Ok(marks)) => { | |
| 212 | + | let _ = event_tx.send(ForgeEvent::PreviewComplete { marks }); | |
| 213 | + | } | |
| 214 | + | Ok(Err(e)) => { | |
| 215 | + | let _ = event_tx.send(ForgeEvent::Error { | |
| 216 | + | error: e.to_string(), | |
| 217 | + | }); | |
| 218 | + | } | |
| 219 | + | Err(_panic) => { | |
| 220 | + | let _ = event_tx.send(ForgeEvent::Error { | |
| 221 | + | error: "chop preview panicked (internal error)".to_string(), | |
| 222 | + | }); | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 190 | 226 | } | |
| 191 | 227 | } | |
| 192 | 228 | } |