Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions src-tauri/src/audio/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! Commands are polled between packets via `cmd_rx.try_recv()` so
//! pause / stop / seek feel responsive even during long tracks.

use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
Expand Down Expand Up @@ -117,17 +118,70 @@ pub fn spawn_decoder_thread(
// `AudioCmd::SwapProducer` when the engine rebuilds the
// cpal output thread on a different device.
let mut producer = producer;
decoder_loop(cmd_rx, &mut producer, shared, app, analytics_tx);
let mut panic_count = 0u32;
const MAX_DECODER_PANICS: u32 = 3;
loop {
let result = catch_unwind(AssertUnwindSafe(|| {
decoder_loop(
&cmd_rx,
&mut producer,
shared.clone(),
app.clone(),
&analytics_tx,
);
}));
match result {
Ok(()) => break,
Err(payload) => {
panic_count += 1;
let message = panic_payload_message(payload.as_ref());
tracing::error!(%message, panic_count, "audio decoder thread panicked");
let _ = app.emit(
EVENT_ERROR,
ErrorPayload {
message: format!("audio decoder crashed: {message}"),
},
);
transition_state(&shared, &app, PlayerState::Idle, None);
if panic_count >= MAX_DECODER_PANICS {
tracing::error!(
panic_count,
"audio decoder thread panicked repeatedly, stopping recovery"
);
let _ = app.emit(
EVENT_ERROR,
ErrorPayload {
message: "audio decoder stopped after repeated crashes"
.to_string(),
},
);
break;
}
let backoff_ms = 100_u64 * (1_u64 << (panic_count - 1));
std::thread::sleep(Duration::from_millis(backoff_ms));
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}

fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
"unknown panic payload".to_string()
}

/// Top-level decoder thread loop. Never returns except on `Shutdown`.
fn decoder_loop(
cmd_rx: Receiver<AudioCmd>,
cmd_rx: &Receiver<AudioCmd>,
producer: &mut Producer<f32>,
shared: Arc<SharedPlayback>,
app: AppHandle,
analytics_tx: UnboundedSender<AnalyticsMsg>,
analytics_tx: &UnboundedSender<AnalyticsMsg>,
) {
// When `play_track` returns due to a mid-decode LoadAndPlay, the
// stashed command lands here so we process it before blocking on
Expand Down
30 changes: 22 additions & 8 deletions src-tauri/src/audio/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ pub struct AudioEngine {
/// flipped by `set_wasapi_exclusive`. Used by `set_output_device`
/// to preserve the mode across hot-swaps.
wasapi_exclusive: std::sync::atomic::AtomicBool,
/// Whether the current output stream is actually running in
/// WASAPI Exclusive Mode. This can differ from the preference
/// when init falls back to cpal shared mode.
wasapi_exclusive_active: std::sync::atomic::AtomicBool,
}

impl AudioEngine {
Expand Down Expand Up @@ -172,13 +176,14 @@ impl AudioEngine {
// rows and self-send the next `LoadAndPlay`.
let (analytics_tx, analytics_rx) = unbounded_channel::<AnalyticsMsg>();

let (output, decoder) = match spawn_output_with_mode(
let (output, decoder, wasapi_exclusive_active) = match spawn_output_with_mode(
shared.clone(),
app.clone(),
device_name,
wasapi_exclusive,
) {
Ok((producer, handle)) => {
let active = handle.wasapi_exclusive;
// `spawn_output_thread` returns only after the cpal
// stream has opened, so `shared.sample_rate` /
// `shared.channels` are already populated by the time
Expand All @@ -190,17 +195,17 @@ impl AudioEngine {
app.clone(),
analytics_tx,
) {
Ok(join) => (Some(handle), Some(join)),
Ok(join) => (Some(handle), Some(join), active),
Err(err) => {
tracing::error!(?err, "failed to spawn decoder thread");
handle.stop();
(None, None)
(None, None, false)
}
}
}
Err(err) => {
tracing::warn!(?err, "failed to open audio output at startup");
(None, None)
(None, None, false)
}
};

Expand All @@ -214,6 +219,7 @@ impl AudioEngine {
decoder: Mutex::new(decoder),
app,
wasapi_exclusive: std::sync::atomic::AtomicBool::new(wasapi_exclusive),
wasapi_exclusive_active: std::sync::atomic::AtomicBool::new(wasapi_exclusive_active),
})
}

Expand Down Expand Up @@ -338,6 +344,10 @@ impl AudioEngine {
}

*guard = Some(handle);
self.wasapi_exclusive_active.store(
guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false),
std::sync::atomic::Ordering::Release,
);

// Step 6 — resume the previous track if we were playing one.
if was_playing && track_id > 0 {
Expand Down Expand Up @@ -418,6 +428,7 @@ impl AudioEngine {

let (producer, handle) =
spawn_output_with_mode(self.shared.clone(), self.app.clone(), active, enabled)?;
let active_mode = handle.wasapi_exclusive;

if was_playing {
self.cmd_tx
Expand All @@ -431,6 +442,8 @@ impl AudioEngine {
.send(AudioCmd::SwapProducer(producer))
.map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?;
*guard = Some(handle);
self.wasapi_exclusive_active
.store(active_mode, std::sync::atomic::Ordering::Release);

if was_playing && track_id > 0 {
let app = self.app.clone();
Expand Down Expand Up @@ -472,10 +485,11 @@ impl AudioEngine {
Ok(())
}

/// Current Windows Exclusive Mode preference. Always `false` on
/// non-Windows.
/// Whether the current output stream is actually running in
/// WASAPI Exclusive Mode. Always `false` on Linux / macOS and
/// also `false` after a Windows fallback to cpal shared mode.
pub fn wasapi_exclusive(&self) -> bool {
self.wasapi_exclusive
.load(std::sync::atomic::Ordering::Relaxed)
self.wasapi_exclusive_active
.load(std::sync::atomic::Ordering::Acquire)
}
}
5 changes: 5 additions & 0 deletions src-tauri/src/audio/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ pub struct OutputHandle {
/// `None` means the OS default device. Saved so a hot-swap can
/// no-op when the user picks the same device again.
pub device_name: Option<String>,
/// Whether this handle is really using WASAPI Exclusive Mode.
/// The user preference can request exclusive mode, but startup may
/// fall back to cpal shared mode when the device rejects it.
pub wasapi_exclusive: bool,
}

impl OutputHandle {
Expand Down Expand Up @@ -333,6 +337,7 @@ pub fn spawn_output_thread(
shutdown_tx,
join,
device_name,
wasapi_exclusive: false,
},
)),
Ok(Err(err)) => {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/audio/wasapi_exclusive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub fn spawn_exclusive_output_thread(
shutdown_tx,
join,
device_name,
wasapi_exclusive: true,
},
)),
Ok(Err(err)) => {
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,14 @@ pub fn run() {
// been armed, so we can safely persist the resume point and
// shut the audio engine down.
WindowEvent::Destroyed => {
if window.label() != "main" {
return;
}
let app = window.app_handle().clone();
let quitting = app.state::<QuitGate>().0.load(Ordering::Acquire);
if !quitting {
return;
}
let _ = tauri::async_runtime::block_on(async move {
let state = app.state::<AppState>();
let engine = app.state::<Arc<AudioEngine>>();
Expand Down
Loading