Skip to main content

max / audiofiles

Frontend: off-thread WAV fallback, device hot-plug recovery, kill render-path panics F4: the hound WAV fallback in start_streaming_decode decoded the whole file inline on the egui frame thread — defeating the streaming path's "never block the UI" guarantee for exactly the large-uncompressed-WAV case. It now runs on the same detached, generation-cancelled, panic-isolated worker as the Symphonia path; the buffer stays None (audio renders silence) until the decode lands. F5: added SharedState::device_lost, set lock-free from the cpal error callback when the output stream faults (e.g. device unplugged). The app now owns the stream and poll_audio_device rebuilds it (throttled ~2s), refreshing the device name and sample rate. Preview recovers instead of going permanently silent. F9: removed the render-loop panic cluster — widgets modal scaffold uses and_then(|r| r.inner) instead of .inner.unwrap(); settings_panel rename rebinds the option mutably instead of as_mut()/as_ref().unwrap(); export_screens uses a slice pattern instead of over_limit[0]. Perf: the five classifier Settings-panel loops (rules, policies, clusters, folder labels, layers) use mem::take + restore instead of deep-cloning the whole Vec every frame while the panel is open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:09 UTC
Commit: 767f573a989c8e9087549ed17dd6a5e43d1ecb4e
Parent: 6d2e936
8 files changed, +170 insertions, -32 deletions
@@ -92,6 +92,12 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
92 92 };
93 93 let mut mix_buf: Vec<f32> = vec![0.0; initial_frames.saturating_mul(channels.max(1))];
94 94
95 + // Clone for the error callback before `shared` is moved into the data
96 + // callback. A stream fault (e.g. the device was unplugged) flips this
97 + // lock-free flag; the app loop polls it and rebuilds the stream so preview
98 + // recovers instead of going permanently silent.
99 + let err_shared = Arc::clone(&shared);
100 +
95 101 let stream = device
96 102 .build_output_stream(
97 103 config,
@@ -123,8 +129,13 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
123 129 *out = T::from_sample(mix.clamp(-1.0, 1.0));
124 130 }
125 131 },
126 - |err| {
132 + move |err| {
127 133 tracing::error!("audio stream error: {err}");
134 + // Signal the app loop to rebuild the stream. Lock-free store only
135 + // — this runs in cpal's error context, never take a lock here.
136 + err_shared
137 + .device_lost
138 + .store(true, std::sync::atomic::Ordering::Relaxed);
128 139 },
129 140 None,
130 141 )?;
@@ -103,8 +103,9 @@ fn main() -> eframe::Result<()> {
103 103
104 104 let shared = Arc::new(SharedState::new());
105 105
106 - // Start cpal audio output stream
107 - let _stream = match audio::start_output_stream(shared.clone()) {
106 + // Start cpal audio output stream. Ownership moves into the app so it can be
107 + // rebuilt on device loss (see AudioFilesApp::poll_audio_device).
108 + let initial_stream = match audio::start_output_stream(shared.clone()) {
108 109 Ok((stream, device_rate, device_name)) => {
109 110 shared.device_sample_rate.store(device_rate, std::sync::atomic::Ordering::Relaxed);
110 111 *shared.preview_device_name.lock() = Some(device_name);
@@ -148,6 +149,7 @@ fn main() -> eframe::Result<()> {
148 149 audiofiles_browser::ui::theme::setup_fonts(&cc.egui_ctx);
149 150 Ok(Box::new(AudioFilesApp::new(
150 151 config_dir, shared, app_tray, update_checker, prefs, runtime, gtk_ok,
152 + initial_stream,
151 153 )))
152 154 }),
153 155 )
@@ -308,6 +310,13 @@ struct AudioFilesApp {
308 310 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
309 311 gtk_ok: bool,
310 312 _runtime: tokio::runtime::Runtime,
313 + /// cpal output stream. Kept alive here (dropping it stops audio) and rebuilt
314 + /// by `poll_audio_device` when `SharedState::device_lost` fires. `None` when
315 + /// no output device is available.
316 + audio_stream: Option<cpal::Stream>,
317 + /// Throttles audio-stream rebuild attempts after a device fault so a
318 + /// persistently-missing device doesn't rebuild every frame.
319 + audio_retry_at: Option<std::time::Instant>,
311 320
312 321 // ── Vault state ──
313 322 vault_registry: Option<VaultRegistry>,
@@ -325,6 +334,7 @@ struct AudioFilesApp {
325 334 }
326 335
327 336 impl AudioFilesApp {
337 + #[allow(clippy::too_many_arguments)]
328 338 fn new(
329 339 config_dir: PathBuf,
330 340 shared: Arc<SharedState>,
@@ -333,6 +343,7 @@ impl AudioFilesApp {
333 343 prefs: preferences::Preferences,
334 344 runtime: tokio::runtime::Runtime,
335 345 gtk_ok: bool,
346 + audio_stream: Option<cpal::Stream>,
336 347 ) -> Self {
337 348 let _ = std::fs::create_dir_all(&config_dir);
338 349 let default_vault = vault::default_vault_path();
@@ -413,6 +424,8 @@ impl AudioFilesApp {
413 424 midi_connection: None,
414 425 gtk_ok,
415 426 _runtime: runtime,
427 + audio_stream,
428 + audio_retry_at: None,
416 429 vault_registry,
417 430 vault_setup_path: None,
418 431 vault_setup_name: "Library".to_string(),
@@ -429,6 +442,44 @@ impl AudioFilesApp {
429 442 app
430 443 }
431 444
445 + /// Rebuild the cpal output stream when the device faults (unplug, format
446 + /// change). `SharedState::device_lost` is set from the audio error callback;
447 + /// here we drop the dead stream and retry, throttled to ~2s so a
448 + /// persistently-missing device doesn't rebuild every frame. On success the
449 + /// footer's device name and sample rate refresh; on failure preview stays
450 + /// silent until the next retry, but the app never wedges.
451 + fn poll_audio_device(&mut self) {
452 + use std::sync::atomic::Ordering;
453 + if !self.shared.device_lost.load(Ordering::Relaxed) {
454 + return;
455 + }
456 + let now = std::time::Instant::now();
457 + if let Some(next) = self.audio_retry_at
458 + && now < next
459 + {
460 + return;
461 + }
462 + self.audio_retry_at = Some(now + std::time::Duration::from_secs(2));
463 +
464 + // Drop the dead stream before opening a new one (a device can't be
465 + // opened twice).
466 + self.audio_stream = None;
467 + match audio::start_output_stream(self.shared.clone()) {
468 + Ok((stream, rate, name)) => {
469 + self.shared.device_sample_rate.store(rate, Ordering::Relaxed);
470 + *self.shared.preview_device_name.lock() = Some(name);
471 + self.shared.device_lost.store(false, Ordering::Relaxed);
472 + self.audio_stream = Some(stream);
473 + tracing::info!("audio output stream rebuilt after device change");
474 + }
475 + Err(e) => {
476 + *self.shared.preview_device_name.lock() = None;
477 + tracing::warn!("audio output still unavailable, will retry: {e}");
478 + // device_lost stays set; retry on the next throttled tick.
479 + }
480 + }
481 + }
482 +
432 483 /// Initialise the browser after successful activation.
433 484 fn activate_browser(&mut self) {
434 485 let _ = std::fs::create_dir_all(&self.data_dir);
@@ -512,6 +563,9 @@ impl eframe::App for AudioFilesApp {
512 563 }
513 564 }
514 565
566 + // Rebuild the audio output stream if the device faulted (e.g. unplugged).
567 + self.poll_audio_device();
568 +
515 569 // Cmd/Ctrl+I toggles the About modal. Works on every screen so a
516 570 // confused user always has one keystroke to "who made this".
517 571 ctx.input_mut(|i| {
@@ -271,16 +271,57 @@ pub fn start_streaming_decode(
271 271 path = %path.display(),
272 272 "symphonia probe failed for streaming, using hound fallback: {e}"
273 273 );
274 - // For WAV fallback, just do a full decode (WAV is PCM, fast to read)
275 - let buf = decode_wav_hound_stereo(path)?;
276 - let mut guard = shared.preview.lock();
277 - let total_frames = buf.data.len() / 2;
278 - guard.buffer = Some(buf);
279 - guard.position_frac = 0.0;
280 - guard.playing = true;
281 - guard.streaming = false;
282 - guard.decoded_frames = total_frames;
283 - guard.total_frames_estimate = Some(total_frames);
274 + // Full-decode fallback for WAVs Symphonia rejects. This MUST run
275 + // off the caller thread: start_streaming_decode is invoked from the
276 + // egui frame thread, and decoding a multi-minute WAV inline froze
277 + // the UI — defeating the whole point of the streaming path. Hand it
278 + // to the same detached, generation-cancelled, panic-isolated worker
279 + // the Symphonia path uses. The buffer stays None (audio callback
280 + // renders silence) until the decode lands.
281 + let my_generation =
282 + shared.decode_generation.fetch_add(1, Ordering::AcqRel) + 1;
283 + {
284 + let mut guard = shared.preview.lock();
285 + guard.buffer = None;
286 + guard.position_frac = 0.0;
287 + guard.playing = false;
288 + guard.streaming = true;
289 + guard.decoded_frames = 0;
290 + guard.total_frames_estimate = None;
291 + }
292 + let shared = std::sync::Arc::clone(shared);
293 + let path = path.to_path_buf();
294 + let _ = audiofiles_core::worker_runtime::spawn_detached(
295 + "af-preview-wav-fallback",
296 + move || {
297 + let _lease = StreamingLease {
298 + shared: std::sync::Arc::clone(&shared),
299 + };
300 + // Superseded before we started.
301 + if shared.decode_generation.load(Ordering::Acquire) != my_generation {
302 + return;
303 + }
304 + let buf = match decode_wav_hound_stereo(&path) {
305 + Ok(b) => b,
306 + Err(e) => {
307 + warn!(path = %path.display(), "hound fallback decode failed: {e}");
308 + return; // lease clears `streaming`
309 + }
310 + };
311 + // A newer preview started while we decoded — drop this result.
312 + if shared.decode_generation.load(Ordering::Acquire) != my_generation {
313 + return;
314 + }
315 + let total_frames = buf.data.len() / 2;
316 + let mut guard = shared.preview.lock();
317 + guard.buffer = Some(buf);
318 + guard.position_frac = 0.0;
319 + guard.playing = true;
320 + guard.streaming = false;
321 + guard.decoded_frames = total_frames;
322 + guard.total_frames_estimate = Some(total_frames);
323 + },
324 + );
284 325 return Ok(());
285 326 }
286 327 return Err(PreviewError::Probe(e.to_string()));
@@ -6,7 +6,7 @@
6 6 use std::fs;
7 7 use std::path::{Path, PathBuf};
8 8 use std::sync::Arc;
9 - use std::sync::atomic::{AtomicU32, AtomicU64};
9 + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
10 10 use std::time::Instant;
11 11
12 12 use tracing::{error, warn};
@@ -96,6 +96,11 @@ pub struct SharedState {
96 96 /// the footer to surface "Preview: <device>" for diagnostic visibility.
97 97 /// `None` means no device is available (audio output failed to start).
98 98 pub preview_device_name: Mutex<Option<String>>,
99 + /// Set by the cpal error callback when the output stream faults (e.g. the
100 + /// device was unplugged). The app loop polls this and rebuilds the stream,
101 + /// so preview recovers instead of going permanently silent. Written from the
102 + /// real-time audio thread, so it must stay lock-free (an atomic, never a Mutex).
103 + pub device_lost: AtomicBool,
99 104 }
100 105
101 106 impl Default for SharedState {
@@ -107,6 +112,7 @@ impl Default for SharedState {
107 112 midi_recent_notes: Mutex::new(Vec::new()),
108 113 decode_generation: AtomicU64::new(0),
109 114 preview_device_name: Mutex::new(None),
115 + device_lost: AtomicBool::new(false),
110 116 }
111 117 }
112 118 }
@@ -221,8 +221,11 @@ fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) {
221 221 return;
222 222 }
223 223
224 - // Collect actions to apply after the immutable iteration over the cached list.
225 - let rules = state.classifier.rules.clone();
224 + // Move the list out to iterate it, then move it back before applying the
225 + // deferred actions (a zero-copy swap replacing a per-frame deep clone of the
226 + // whole rules Vec). The loop body reads only the local list + `ui`, never
227 + // `state.classifier.rules`, so the temporary empty field is never observed.
228 + let rules = std::mem::take(&mut state.classifier.rules);
226 229 let count = rules.len();
227 230 let mut toggle: Option<(String, bool)> = None;
228 231 let mut edit: Option<usize> = None;
@@ -264,11 +267,14 @@ fn draw_list(ui: &mut egui::Ui, state: &mut BrowserState) {
264 267 });
265 268 }
266 269
270 + // Restore the list before applying deferred actions (which mutate it).
271 + state.classifier.rules = rules;
272 +
267 273 if let Some((id, enabled)) = toggle {
268 274 state.classifier_toggle_rule(&id, enabled);
269 275 }
270 276 if let Some(i) = edit {
271 - let rule = rules[i].clone();
277 + let rule = state.classifier.rules[i].clone();
272 278 state.classifier_edit_rule(&rule);
273 279 }
274 280 if let Some(id) = delete {
@@ -499,7 +505,7 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) {
499 505 );
500 506 state.ensure_policies_loaded();
501 507
502 - let policies = state.classifier.policies.clone();
508 + let policies = std::mem::take(&mut state.classifier.policies);
503 509 let mut changed: Option<(String, f64, f64)> = None;
504 510 for (tag, policy) in &policies {
505 511 let mut review = policy.review_threshold as f32;
@@ -520,6 +526,7 @@ fn draw_autotag_header(ui: &mut egui::Ui, state: &mut BrowserState) {
520 526 }
521 527 });
522 528 }
529 + state.classifier.policies = policies;
523 530 if let Some((tag, review, auto)) = changed {
524 531 state.classifier_set_policy(&tag, review, auto);
525 532 state.refresh_policies();
@@ -640,7 +647,7 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) {
640 647 }
641 648 ui.add_space(theme::space::SM);
642 649
643 - let clusters = state.classifier.clusters.clone();
650 + let clusters = std::mem::take(&mut state.classifier.clusters);
644 651 let mut play: Option<String> = None;
645 652 let mut apply: Option<usize> = None;
646 653 for (i, cluster) in clusters.iter().enumerate() {
@@ -665,6 +672,7 @@ fn draw_cluster_header(ui: &mut egui::Ui, state: &mut BrowserState) {
665 672 }
666 673 });
667 674 }
675 + state.classifier.clusters = clusters;
668 676 if let Some(hash) = play {
669 677 state.trigger_preview(&hash);
670 678 }
@@ -723,7 +731,7 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) {
723 731 }
724 732 ui.add_space(theme::space::SM);
725 733
726 - let labels = state.classifier.folder_labels.clone();
734 + let labels = std::mem::take(&mut state.classifier.folder_labels);
727 735 let mut apply: Option<usize> = None;
728 736 for (i, label) in labels.iter().enumerate() {
729 737 ui.horizontal(|ui| {
@@ -743,6 +751,7 @@ fn draw_folder_header(ui: &mut egui::Ui, state: &mut BrowserState) {
743 751 }
744 752 });
745 753 }
754 + state.classifier.folder_labels = labels;
746 755 if let Some(i) = apply {
747 756 state.apply_folder_label(i);
748 757 }
@@ -839,8 +848,9 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
839 848 }
840 849
841 850 // ── Imported layers ──
842 - let layers = state.classifier.layers.clone();
851 + let layers = std::mem::take(&mut state.classifier.layers);
843 852 if layers.is_empty() {
853 + state.classifier.layers = layers; // restore (empty) — keep field consistent
844 854 return;
845 855 }
846 856 ui.add_space(theme::space::MD);
@@ -929,6 +939,7 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
929 939 );
930 940 }
931 941 }
942 + state.classifier.layers = layers;
932 943 if let Some(p) = set_pending {
933 944 state.classifier.pending_layer_remove = p;
934 945 }
@@ -130,10 +130,10 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
130 130 .map(|item| item.name.as_str())
131 131 .collect();
132 132 if !over_limit.is_empty() {
133 - let msg = if over_limit.len() == 1 {
133 + let msg = if let [only] = over_limit.as_slice() {
134 134 format!(
135 135 "\"{}\" may exceed device file size limit ({:.0} MB)",
136 - over_limit[0],
136 + only,
137 137 max_bytes as f64 / 1_048_576.0,
138 138 )
139 139 } else {
@@ -207,20 +207,32 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
207 207 }
208 208
209 209 // Inline rename
210 - if let Some((ref rename_path, _)) = state.settings.rename_target.clone() {
210 + if let Some((rename_path, _)) = state.settings.rename_target.clone() {
211 211 ui.separator();
212 212 ui.horizontal(|ui| {
213 213 ui.label("New name:");
214 - let resp = ui.text_edit_singleline(
215 - &mut state.settings.rename_target.as_mut().unwrap().1,
216 - );
217 - if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
218 - let new_name = state.settings.rename_target.as_ref().unwrap().1.clone();
219 - if !new_name.trim().is_empty() {
214 + // Borrow the editable name only for the text field; the mutable
215 + // borrow ends before we may clear rename_target below (avoids
216 + // the previous `as_mut().unwrap()` / `as_ref().unwrap()` pair,
217 + // which panicked-per-frame if the option was cleared mid-frame).
218 + let mut submit = false;
219 + if let Some((_, name)) = state.settings.rename_target.as_mut() {
220 + let resp = ui.text_edit_singleline(name);
221 + submit = resp.lost_focus()
222 + && ui.input(|i| i.key_pressed(egui::Key::Enter));
223 + }
224 + if submit {
225 + let new_name = state
226 + .settings
227 + .rename_target
228 + .as_ref()
229 + .map(|(_, n)| n.trim().to_string())
230 + .unwrap_or_default();
231 + if !new_name.is_empty() {
220 232 state.settings.pending_action =
221 233 Some(crate::state::VaultAction::RenameVault {
222 234 path: rename_path.clone(),
223 - new_name: new_name.trim().to_string(),
235 + new_name,
224 236 });
225 237 }
226 238 state.settings.rename_target = None;
@@ -133,7 +133,10 @@ pub fn modal_window_with_open<R>(
133 133 if let Some(w) = default_width {
134 134 window = window.default_width(w);
135 135 }
136 - window.show(ctx, |ui| add_contents(ui)).map(|r| r.inner.unwrap())
136 + // `inner` is None only if the window collapsed; every caller sets
137 + // collapsible(false), but `and_then` degrades to None instead of a
138 + // per-frame panic in the shared modal scaffold if that ever changes.
139 + window.show(ctx, |ui| add_contents(ui)).and_then(|r| r.inner)
137 140 }
138 141
139 142 /// Render a `[Cancel] [primary]` action row at the bottom of a modal.