Skip to main content

max / audiofiles

Move chop-preview decode off the egui thread (Chronic #1, part 2/3) The chop-preview overlay computed its slice marks by fully decoding the source file synchronously on the GUI thread (ultra-fuzz Perf S3 / Storage decode). That work now runs on the existing core forge worker: new ForgeCommand::Preview -> compute_preview_marks (decode + slice, panic-isolated, writes nothing) -> ForgeEvent::PreviewComplete -> BackendEvent::ChopPreviewComplete, applied to forge.slice_marks in poll_workers. forge_preview_slices now just dispatches and sets the busy flag. The Backend trait's synchronous compute_chop_preview is replaced by start_chop_preview, removing the last UI-reachable synchronous full-file decode. With that gone, export::decode::decode_multichannel is now pub(crate) in core — reachable only from core worker/runner code, so a synchronous decode can't creep back onto the frame thread. compute_preview_marks lives next to chop_to_temp in forge::runner. Tests: core 531 green (new preview_marks tiling test), browser 223 green (chop test drives the async preview via poll_events). clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:09 UTC
Commit: 559712f3ee77696a5f41d2d0d12c7f750bd7c0cf
Parent: 756e87b
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 }