Skip to main content

max / audiofiles

Move preview + instrument decode off the egui thread (Chronic #1, part 1/3) Preview playback now always routes through start_streaming_decode (background thread), dropping the old synchronous decode_to_f32 path that froze the UI on short or unknown-duration files (ultra-fuzz Perf S1). Instrument loading (chromatic auto-load on every preview + multi-sample zone adds) now decodes on a new InstrumentDecodeWorker — a persistent, panic-isolated background thread — with the zone built on the GUI thread in apply_instrument_decode when the result arrives. Rapid arrow-key previewing drops superseded chromatic loads via a generation counter (Perf S2). Seal: decode_to_f32 is now module-private to preview.rs. UI code can reach a full-file decode only through start_streaming_decode or the worker, both off-thread — a synchronous decode in browser UI code is now a compile error. Removed the now-dead STREAMING_THRESHOLD_SECS. Wired worker draining into poll_workers. Tests: 223 browser green (2 new worker round-trip/error tests), clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:03 UTC
Commit: 756e87b61d2679636fbbb1d8df56f1c9c7e01146
Parent: f8a8ff7
4 files changed, +324 insertions, -100 deletions
@@ -68,8 +68,14 @@ impl PreviewPlayback {
68 68
69 69 /// Decode an audio file to interleaved stereo f32.
70 70 /// Mono files are doubled to stereo. Multi-channel files are mixed down to stereo.
71 + ///
72 + /// Module-private by design: a full-file Symphonia decode must never run on the
73 + /// egui frame thread. UI code reaches decoding only through
74 + /// [`start_streaming_decode`] (preview playback) or [`InstrumentDecodeWorker`]
75 + /// (instrument loading), both of which run it on a background thread. Keeping
76 + /// this private makes a synchronous decode in UI code a compile error.
71 77 #[instrument(skip_all)]
72 - pub fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> {
78 + fn decode_to_f32(path: &Path) -> Result<PreviewBuffer, PreviewError> {
73 79 let file = std::fs::File::open(path).map_err(|e| PreviewError::Open {
74 80 path: path.to_path_buf(),
75 81 source: e,
@@ -224,9 +230,6 @@ pub fn estimate_duration(path: &Path) -> Option<f64> {
224 230 None
225 231 }
226 232
227 - /// Duration threshold in seconds: files longer than this use streaming decode.
228 - pub const STREAMING_THRESHOLD_SECS: f64 = 30.0;
229 -
230 233 /// Number of seconds to pre-fill before enabling playback during streaming.
231 234 const PREFILL_SECS: f64 = 0.5;
232 235
@@ -412,6 +415,137 @@ pub fn start_streaming_decode(
412 415 Ok(())
413 416 }
414 417
418 + // ---------- Off-thread decode for instrument loading ----------
419 +
420 + use std::path::PathBuf;
421 + use std::sync::mpsc::{Receiver, Sender};
422 + use std::thread::JoinHandle;
423 +
424 + /// What a finished decode is used for once it returns to the GUI thread.
425 + #[derive(Clone)]
426 + pub enum DecodePurpose {
427 + /// Load one sample across the whole keyboard (chromatic mode), at `root_note`.
428 + Chromatic { root_note: u8 },
429 + /// Add one zone to the multi-sample instrument.
430 + Zone {
431 + name: String,
432 + low: u8,
433 + high: u8,
434 + root: u8,
435 + },
436 + }
437 +
438 + struct DecodeRequest {
439 + generation: u64,
440 + hash: String,
441 + path: PathBuf,
442 + purpose: DecodePurpose,
443 + }
444 +
445 + /// A finished decode handed back to the GUI thread for application.
446 + pub struct DecodeOutcome {
447 + /// Monotonic id of the request that produced this. The GUI drops superseded
448 + /// chromatic auto-loads by comparing against the latest dispatched id.
449 + pub generation: u64,
450 + pub hash: String,
451 + pub purpose: DecodePurpose,
452 + pub result: Result<PreviewBuffer, PreviewError>,
453 + }
454 +
455 + /// Background decoder for instrument loading.
456 + ///
457 + /// Building an instrument zone needs the whole file decoded to an owned
458 + /// `PreviewBuffer`; doing that on the egui thread froze the UI on every preview
459 + /// (ultra-fuzz Perf S2). One persistent thread processes requests serially. The
460 + /// job body is panic-isolated, so a corrupt file surfaces as a `DecodeOutcome`
461 + /// error rather than killing the worker (mirrors the analysis/edit/forge workers).
462 + pub struct InstrumentDecodeWorker {
463 + tx: Option<Sender<DecodeRequest>>,
464 + rx: Receiver<DecodeOutcome>,
465 + handle: Option<JoinHandle<()>>,
466 + next_generation: u64,
467 + }
468 +
469 + impl InstrumentDecodeWorker {
470 + /// Spawn the decode thread.
471 + pub fn new() -> Self {
472 + let (req_tx, req_rx) = std::sync::mpsc::channel::<DecodeRequest>();
473 + let (out_tx, out_rx) = std::sync::mpsc::channel::<DecodeOutcome>();
474 + let handle = std::thread::Builder::new()
475 + .name("af-instrument-decode".into())
476 + .spawn(move || {
477 + while let Ok(req) = req_rx.recv() {
478 + let path = req.path.clone();
479 + let result = match std::panic::catch_unwind(move || decode_to_f32(&path)) {
480 + Ok(r) => r,
481 + Err(_) => Err(PreviewError::Decoder("decoder panicked".to_string())),
482 + };
483 + if out_tx
484 + .send(DecodeOutcome {
485 + generation: req.generation,
486 + hash: req.hash,
487 + purpose: req.purpose,
488 + result,
489 + })
490 + .is_err()
491 + {
492 + break; // GUI gone
493 + }
494 + }
495 + })
496 + .expect("spawn instrument decode worker");
497 + Self {
498 + tx: Some(req_tx),
499 + rx: out_rx,
500 + handle: Some(handle),
501 + next_generation: 0,
502 + }
503 + }
504 +
505 + /// Queue a decode. Returns the generation stamped on this request so the
506 + /// caller can recognise and ignore superseded chromatic auto-loads.
507 + pub fn request(&mut self, hash: String, path: PathBuf, purpose: DecodePurpose) -> u64 {
508 + self.next_generation += 1;
509 + let generation = self.next_generation;
510 + if let Some(tx) = &self.tx {
511 + let _ = tx.send(DecodeRequest {
512 + generation,
513 + hash,
514 + path,
515 + purpose,
516 + });
517 + }
518 + generation
519 + }
520 +
521 + /// Drain all finished decodes (non-blocking).
522 + pub fn drain(&self) -> Vec<DecodeOutcome> {
523 + let mut out = Vec::new();
524 + // `try_recv` errors on both Empty and Disconnected; either ends the drain.
525 + while let Ok(o) = self.rx.try_recv() {
526 + out.push(o);
527 + }
528 + out
529 + }
530 + }
531 +
532 + impl Default for InstrumentDecodeWorker {
533 + fn default() -> Self {
534 + Self::new()
535 + }
536 + }
537 +
538 + impl Drop for InstrumentDecodeWorker {
539 + fn drop(&mut self) {
540 + // Close the request channel so the worker loop's recv() returns Err and
541 + // the thread exits, then join it.
542 + self.tx = None;
543 + if let Some(h) = self.handle.take() {
544 + let _ = h.join();
545 + }
546 + }
547 + }
548 +
415 549 /// Convert interleaved multi-channel samples to interleaved stereo.
416 550 fn interleaved_to_stereo(
417 551 samples: &[f32],
@@ -531,6 +665,58 @@ mod tests {
531 665 }
532 666
533 667 #[test]
668 + fn instrument_decode_worker_round_trips_a_file() {
669 + let dir = tempfile::tempdir().unwrap();
670 + let path = dir.path().join("zone.wav");
671 + write_wav(&path, 1, 44100, &[0.5, -0.5, 0.25, 0.1]);
672 +
673 + let mut worker = InstrumentDecodeWorker::new();
674 + let generation = worker.request(
675 + "deadbeef".to_string(),
676 + path,
677 + DecodePurpose::Chromatic { root_note: 60 },
678 + );
679 + assert_eq!(generation, 1);
680 +
681 + // Poll for the outcome (worker decodes on its own thread).
682 + let outcome = loop {
683 + let mut drained = worker.drain();
684 + if let Some(o) = drained.pop() {
685 + break o;
686 + }
687 + std::thread::yield_now();
688 + };
689 + assert_eq!(outcome.generation, 1);
690 + assert_eq!(outcome.hash, "deadbeef");
691 + let buf = outcome.result.expect("decode should succeed");
692 + assert_eq!(buf.channels, 2);
693 + assert_eq!(buf.data.len(), 8); // 4 mono frames doubled to stereo
694 + }
695 +
696 + #[test]
697 + fn instrument_decode_worker_reports_error_for_missing_file() {
698 + let mut worker = InstrumentDecodeWorker::new();
699 + worker.request(
700 + "nope".to_string(),
701 + PathBuf::from("/nonexistent/missing.wav"),
702 + DecodePurpose::Zone {
703 + name: "x".into(),
704 + low: 0,
705 + high: 127,
706 + root: 60,
707 + },
708 + );
709 + let outcome = loop {
710 + let mut drained = worker.drain();
711 + if let Some(o) = drained.pop() {
712 + break o;
713 + }
714 + std::thread::yield_now();
715 + };
716 + assert!(outcome.result.is_err());
717 + }
718 +
719 + #[test]
534 720 fn decode_mono_duplicates_to_stereo() {
535 721 let dir = tempfile::tempdir().unwrap();
536 722 let path = dir.path().join("mono.wav");
@@ -418,9 +418,17 @@ impl BrowserState {
418 418 /// Poll all backend workers for events. Returns `true` if any events were processed.
419 419 /// Called each frame to drain import, analysis, and export progress.
420 420 pub fn poll_workers(&mut self) -> bool {
421 + // Apply any finished off-thread instrument decodes first (independent of
422 + // the backend worker channels).
423 + let decodes = self.instrument_decode.drain();
424 + let decode_activity = !decodes.is_empty();
425 + for outcome in decodes {
426 + self.apply_instrument_decode(outcome);
427 + }
428 +
421 429 let events = self.backend.poll_events();
422 430 if events.is_empty() {
423 - return false;
431 + return decode_activity;
424 432 }
425 433
426 434 use crate::backend::BackendEvent;
@@ -297,6 +297,14 @@ pub struct BrowserState {
297 297 // Forge — floating sample-forge window (chop / conform / batch)
298 298 pub forge: ForgeUiState,
299 299
300 + /// Off-thread decoder for instrument loading (chromatic auto-load + zone
301 + /// adds). Keeps the full-file decode off the egui frame thread.
302 + pub instrument_decode: crate::preview::InstrumentDecodeWorker,
303 + /// Generation of the most recently dispatched chromatic auto-load. Outcomes
304 + /// with an older generation are dropped so rapid arrow-key previewing can't
305 + /// load a stale instrument over the newest one.
306 + pub latest_chromatic_gen: u64,
307 +
300 308 // Display density
301 309 pub row_height: f32,
302 310
@@ -549,6 +557,8 @@ impl BrowserState {
549 557 show_collection_create: false,
550 558 edit: EditUiState::default(),
551 559 forge: ForgeUiState::default(),
560 + instrument_decode: crate::preview::InstrumentDecodeWorker::new(),
561 + latest_chromatic_gen: 0,
552 562 row_height,
553 563 show_vfs_banner: !vfs_explained,
554 564 show_first_launch_hint: !hints_dismissed,
@@ -16,19 +16,16 @@ impl BrowserState {
16 16 Ok(path)
17 17 }
18 18
19 - /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer.
20 - pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, crate::error::PreviewError> {
21 - let path = self.resolve_sample_path(hash)?;
22 - crate::preview::decode_to_f32(&path)
23 - }
24 -
25 19 // --- Preview ---
26 20
27 21 /// Decode a sample by hash and start playback through the shared preview buffer.
28 22 ///
29 - /// Short files (<=30s or unknown duration) are decoded fully on the GUI thread.
30 - /// Long files use streaming: a background thread decodes while playback starts
31 - /// after a 0.5s pre-fill, avoiding UI freezes.
23 + /// All decoding runs on a background thread via [`start_streaming_decode`]:
24 + /// playback begins after a 0.5s pre-fill (or at decode-end for short files),
25 + /// so the egui thread never blocks on a decode regardless of file length or
26 + /// whether the duration is known (ultra-fuzz Perf S1).
27 + ///
28 + /// [`start_streaming_decode`]: crate::preview::start_streaming_decode
32 29 pub fn trigger_preview(&mut self, hash: &str) {
33 30 let path = match self.resolve_sample_path(hash) {
34 31 Ok(p) => p,
@@ -39,48 +36,24 @@ impl BrowserState {
39 36 }
40 37 };
41 38
42 - let duration = crate::preview::estimate_duration(&path);
43 - let use_streaming = duration.is_some_and(|d| d > crate::preview::STREAMING_THRESHOLD_SECS);
44 -
45 39 let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
46 - let ok = if use_streaming {
47 - match crate::preview::start_streaming_decode(&path, &self.shared) {
48 - Ok(()) => {
49 - self.previewing_hash = Some(hash.to_string());
50 - self.status = format!("Playing: {file_name}");
51 - true
52 - }
53 - Err(e) => {
54 - self.status = format!("Decode error: {e}");
55 - self.previewing_hash = None;
56 - false
57 - }
40 + // Set the loop flag the streaming thread will honour once it starts.
41 + self.shared.preview.lock().loop_enabled = self.loop_enabled;
42 + match crate::preview::start_streaming_decode(&path, &self.shared) {
43 + Ok(()) => {
44 + self.previewing_hash = Some(hash.to_string());
45 + self.status = format!("Playing: {file_name}");
58 46 }
59 - } else {
60 - match crate::preview::decode_to_f32(&path) {
61 - Ok(buf) => {
62 - let mut playback = self.shared.preview.lock();
63 - playback.buffer = Some(buf);
64 - playback.position_frac = 0.0;
65 - playback.playing = true;
66 - playback.loop_enabled = self.loop_enabled;
67 - playback.streaming = false;
68 - playback.decoded_frames = 0;
69 - playback.total_frames_estimate = None;
70 - self.previewing_hash = Some(hash.to_string());
71 - self.status = format!("Playing: {file_name}");
72 - true
73 - }
74 - Err(e) => {
75 - self.status = format!("Decode error: {e}");
76 - self.previewing_hash = None;
77 - false
78 - }
47 + Err(e) => {
48 + self.status = format!("Decode error: {e}");
49 + self.previewing_hash = None;
50 + return;
79 51 }
80 - };
52 + }
81 53
82 - // Auto-load previewed sample into the instrument (unless locked)
83 - if ok && !self.instrument_locked {
54 + // Auto-load previewed sample into the instrument (unless locked). The
55 + // decode runs off-thread; the instrument is built when the result arrives.
56 + if !self.instrument_locked {
84 57 let hash_owned = hash.to_string();
85 58 self.load_chromatic_sample(&hash_owned);
86 59 }
@@ -134,17 +107,20 @@ impl BrowserState {
134 107
135 108 // --- Instrument ---
136 109
137 - /// Load a sample for chromatic instrument playback (pitch-shift across the keyboard).
110 + /// Queue a sample to load for chromatic instrument playback (pitch-shift
111 + /// across the keyboard). The full-file decode runs on the instrument decode
112 + /// worker; the zone is built in [`apply_instrument_decode`] when the result
113 + /// arrives. Newer calls supersede older ones via the generation counter.
138 114 pub fn load_chromatic_sample(&mut self, hash: &str) {
139 - let buf = match self.resolve_and_decode(hash) {
140 - Ok(b) => b,
115 + let path = match self.resolve_sample_path(hash) {
116 + Ok(p) => p,
141 117 Err(e) => {
142 118 self.status = e.to_string();
143 119 return;
144 120 }
145 121 };
146 122
147 - // Derive root note from analysis, default to C3 (48)
123 + // Derive root note from analysis (cheap DB read), default to C3 (48).
148 124 let root_note = self
149 125 .backend
150 126 .get_analysis(hash)
@@ -154,30 +130,11 @@ impl BrowserState {
154 130 .and_then(|k| audiofiles_core::instrument::key_to_root_note(&k))
155 131 .unwrap_or(48);
156 132
157 - let zone = crate::instrument::LoadedZone {
158 - buffer: buf,
159 - root_note,
160 - low_note: 0,
161 - high_note: 127,
162 - vel_low: 0.0,
163 - vel_high: 1.0,
164 - };
165 -
166 - let mut inst = self.shared.instrument.lock();
167 - inst.config.mode = audiofiles_core::instrument::InstrumentMode::Chromatic;
168 - inst.zone_buffers.clear();
169 - inst.zone_buffers.push(zone);
170 - inst.active = true;
171 - inst.sample_rate = self.sample_rate;
172 - // Kill all voices
173 - for voice in &mut inst.voices {
174 - voice.active = false;
175 - voice.envelope_phase = crate::instrument::EnvelopePhase::Idle;
176 - voice.envelope_level = 0.0;
177 - }
178 - drop(inst);
179 -
180 - self.instrument_root_note = root_note;
133 + self.latest_chromatic_gen = self.instrument_decode.request(
134 + hash.to_string(),
135 + path,
136 + crate::preview::DecodePurpose::Chromatic { root_note },
137 + );
181 138 }
182 139
183 140 /// Toggle instrument mode on/off.
@@ -188,35 +145,98 @@ impl BrowserState {
188 145 self.show_midi_window = inst.active;
189 146 }
190 147
191 - /// Add a sample as a new zone in multi-sample instrument mode.
148 + /// Queue a sample to be added as a new zone in multi-sample instrument mode.
149 + /// The decode runs off-thread; the zone is built in [`apply_instrument_decode`].
192 150 pub fn add_instrument_zone(&mut self, hash: &str, name: &str, low: u8, high: u8, root: u8) {
193 - let buf = match self.resolve_and_decode(hash) {
194 - Ok(b) => b,
151 + let path = match self.resolve_sample_path(hash) {
152 + Ok(p) => p,
195 153 Err(e) => {
196 154 self.status = e.to_string();
197 155 return;
198 156 }
199 157 };
200 158
201 - let zone = crate::instrument::LoadedZone {
202 - buffer: buf,
203 - root_note: root,
204 - low_note: low,
205 - high_note: high,
206 - vel_low: 0.0,
207 - vel_high: 1.0,
159 + self.instrument_decode.request(
160 + hash.to_string(),
161 + path,
162 + crate::preview::DecodePurpose::Zone {
163 + name: name.to_string(),
164 + low,
165 + high,
166 + root,
167 + },
168 + );
169 + }
170 +
171 + /// Apply a finished instrument decode (called from the poll loop). Builds the
172 + /// instrument zone on the GUI thread from the off-thread-decoded buffer.
173 + pub fn apply_instrument_decode(&mut self, outcome: crate::preview::DecodeOutcome) {
174 + use crate::preview::DecodePurpose;
175 +
176 + let buf = match outcome.result {
177 + Ok(b) => b,
178 + Err(e) => {
179 + self.status = format!("Instrument decode failed: {e}");
180 + return;
181 + }
208 182 };
209 183
210 - let mut inst = self.shared.instrument.lock();
211 - inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample;
212 - inst.zone_buffers.push(zone);
213 - inst.active = true;
214 - inst.sample_rate = self.sample_rate;
215 - drop(inst);
216 -
217 - self.instrument_visible = true;
218 - self.show_midi_window = true;
219 - self.status = format!("Added zone: {name} ({}-{})", low, high);
184 + match outcome.purpose {
185 + DecodePurpose::Chromatic { root_note } => {
186 + // Drop superseded auto-loads (rapid arrow-key previewing).
187 + if outcome.generation != self.latest_chromatic_gen {
188 + return;
189 + }
190 + let zone = crate::instrument::LoadedZone {
191 + buffer: buf,
192 + root_note,
193 + low_note: 0,
194 + high_note: 127,
195 + vel_low: 0.0,
196 + vel_high: 1.0,
197 + };
198 + let mut inst = self.shared.instrument.lock();
199 + inst.config.mode = audiofiles_core::instrument::InstrumentMode::Chromatic;
200 + inst.zone_buffers.clear();
201 + inst.zone_buffers.push(zone);
202 + inst.active = true;
203 + inst.sample_rate = self.sample_rate;
204 + // Kill all voices
205 + for voice in &mut inst.voices {
206 + voice.active = false;
207 + voice.envelope_phase = crate::instrument::EnvelopePhase::Idle;
208 + voice.envelope_level = 0.0;
209 + }
210 + drop(inst);
211 +
212 + self.instrument_root_note = root_note;
213 + }
214 + DecodePurpose::Zone {
215 + name,
216 + low,
217 + high,
218 + root,
219 + } => {
220 + let zone = crate::instrument::LoadedZone {
221 + buffer: buf,
222 + root_note: root,
223 + low_note: low,
224 + high_note: high,
225 + vel_low: 0.0,
226 + vel_high: 1.0,
227 + };
228 + let mut inst = self.shared.instrument.lock();
229 + inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample;
230 + inst.zone_buffers.push(zone);
231 + inst.active = true;
232 + inst.sample_rate = self.sample_rate;
233 + drop(inst);
234 +
235 + self.instrument_visible = true;
236 + self.show_midi_window = true;
237 + self.status = format!("Added zone: {name} ({low}-{high})");
238 + }
239 + }
220 240 }
221 241
222 242 /// Remove a zone by index and kill any voices using it.