From dc8c1bdd6c45833b821e0084f9e7500e70eea396 Mon Sep 17 00:00:00 2001 From: Alexandr Klimok Date: Mon, 13 Jul 2026 21:10:44 +0400 Subject: [PATCH 1/5] fix: stop recreating the audio feedback output stream per transcription Opening a WASAPI output stream (or enumerating output devices for an explicit selection) on every chime races concurrent audio session state. When that call wedges, it takes the rest of the audio stack down with it: the transcription task blocks inside the microphone stream shutdown and the pipeline stays "busy" forever (Transcribing... overlay never resolves, only killing Handy recovers). Move playback to a dedicated long-lived worker that opens the output stream once (pre-warmed at startup, while no transcription is running) and reuses it, recreating only on device change or playback error. Blocking callers (start-chime -> mute sequencing, test sound) now wait with a bounded timeout, so even a wedged audio stack cannot stall the callers. Co-Authored-By: Claude Fable 5 --- src-tauri/src/audio_feedback.rs | 189 ++++++++++++++++++++++++-------- src-tauri/src/lib.rs | 5 + 2 files changed, 146 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index ef759a1384..1bd85098ba 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -6,7 +6,10 @@ use rodio::OutputStreamBuilder; use std::fs::File; use std::io::BufReader; use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::OnceLock; use std::thread; +use std::time::Duration; use tauri::{AppHandle, Manager}; pub enum SoundType { @@ -14,6 +17,11 @@ pub enum SoundType { Stop, } +/// How long a caller that needs the chime to finish (to sequence muting +/// after it) is allowed to wait. If the audio stack is wedged, callers +/// proceed without sound instead of hanging the transcription pipeline. +const BLOCKING_PLAY_TIMEOUT: Duration = Duration::from_secs(3); + fn resolve_sound_path( app: &AppHandle, settings: &AppSettings, @@ -45,13 +53,54 @@ fn get_sound_base_dir(settings: &AppSettings) -> tauri::path::BaseDirectory { } } +enum Request { + /// Ensure the output stream for `device` exists (startup pre-warm). + Warm { + device: Option, + }, + Play { + path: PathBuf, + device: Option, + volume: f32, + done: Option>, + }, +} + +static PLAYER: OnceLock> = OnceLock::new(); + +fn player() -> &'static mpsc::Sender { + PLAYER.get_or_init(|| { + let (tx, rx) = mpsc::channel(); + thread::Builder::new() + .name("audio-feedback".into()) + .spawn(move || playback_worker(rx)) + .expect("failed to spawn audio feedback thread"); + tx + }) +} + +/// Pre-warm the output stream at startup, while no transcription is running. +/// Opening the stream is the WASAPI call that can wedge when it races other +/// audio session activity, taking the rest of the audio stack (including the +/// microphone stream shutdown on the transcription path) down with it — so it +/// happens once here and on device change, never per transcription. +pub fn init(app: &AppHandle) { + let settings = settings::get_settings(app); + if !settings.audio_feedback { + return; + } + let _ = player().send(Request::Warm { + device: settings.selected_output_device.clone(), + }); +} + pub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) { let settings = settings::get_settings(app); if !settings.audio_feedback { return; } if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - play_sound_async(app, path); + send_play(&settings, path, None); } } @@ -61,66 +110,108 @@ pub fn play_feedback_sound_blocking(app: &AppHandle, sound_type: SoundType) { return; } if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - play_sound_blocking(app, &path); + wait_for_play(&settings, path); } } pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) { let settings = settings::get_settings(app); if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - play_sound_blocking(app, &path); + wait_for_play(&settings, path); } } -fn play_sound_async(app: &AppHandle, path: PathBuf) { - let app_handle = app.clone(); - thread::spawn(move || { - if let Err(e) = play_sound_at_path(&app_handle, path.as_path()) { - error!("Failed to play sound '{}': {}", path.display(), e); - } +fn send_play(settings: &AppSettings, path: PathBuf, done: Option>) { + let _ = player().send(Request::Play { + path, + device: settings.selected_output_device.clone(), + volume: settings.audio_feedback_volume, + done, }); } -fn play_sound_blocking(app: &AppHandle, path: &Path) { - if let Err(e) = play_sound_at_path(app, path) { - error!("Failed to play sound '{}': {}", path.display(), e); +fn wait_for_play(settings: &AppSettings, path: PathBuf) { + let (tx, rx) = mpsc::channel(); + send_play(settings, path, Some(tx)); + if rx.recv_timeout(BLOCKING_PLAY_TIMEOUT).is_err() { + warn!( + "Audio feedback did not finish within {:?}; continuing without it", + BLOCKING_PLAY_TIMEOUT + ); } } -fn play_sound_at_path(app: &AppHandle, path: &Path) -> Result<(), Box> { - let settings = settings::get_settings(app); - let volume = settings.audio_feedback_volume; - let selected_device = settings.selected_output_device.clone(); - play_audio_file(path, selected_device, volume) -} +fn playback_worker(rx: mpsc::Receiver) { + // The output stream is created once and kept open across transcriptions. + // Recreating it per chime is what raced concurrent WASAPI session state + // and could deadlock the whole audio stack, mic stream shutdown included. + let mut cached: Option<(Option, rodio::OutputStream)> = None; -fn play_audio_file( - path: &std::path::Path, - selected_device: Option, - volume: f32, -) -> Result<(), Box> { - let stream_builder = if let Some(device_name) = selected_device { - if device_name == "Default" { - debug!("Using default device"); - OutputStreamBuilder::from_default_device()? - } else { - let host = crate::audio_toolkit::get_cpal_host(); - let devices = host.output_devices()?; - - let mut found_device = None; - for device in devices { - if device.name()? == device_name { - found_device = Some(device); - break; + while let Ok(req) = rx.recv() { + match req { + Request::Warm { device } => { + ensure_stream(&mut cached, device); + } + Request::Play { + path, + device, + volume, + done, + } => { + if let Some((_, stream)) = ensure_stream(&mut cached, device) { + if let Err(e) = play_on_stream(stream, &path, volume) { + error!( + "Failed to play sound '{}': {}; dropping cached output stream", + path.display(), + e + ); + cached = None; + } + } + if let Some(done) = done { + let _ = done.send(()); } } + } + } +} - match found_device { - Some(device) => OutputStreamBuilder::from_device(device)?, - None => { - warn!("Device '{}' not found, using default device", device_name); - OutputStreamBuilder::from_default_device()? - } +fn ensure_stream( + cached: &mut Option<(Option, rodio::OutputStream)>, + device: Option, +) -> Option<&(Option, rodio::OutputStream)> { + let stale = cached.as_ref().map(|(d, _)| d != &device).unwrap_or(true); + if stale { + // Drop any previous stream before opening the replacement. + *cached = None; + match create_stream(device.as_deref()) { + Ok(stream) => *cached = Some((device, stream)), + Err(e) => error!("Failed to open audio feedback output stream: {}", e), + } + } + cached.as_ref() +} + +fn create_stream( + device_name: Option<&str>, +) -> Result> { + let stream_builder = if let Some(name) = device_name.filter(|n| *n != "Default") { + let host = crate::audio_toolkit::get_cpal_host(); + let devices = host.output_devices()?; + + let mut found_device = None; + for device in devices { + if device.name()? == name { + found_device = Some(device); + break; + } + } + + match found_device { + Some(device) => OutputStreamBuilder::from_device(device)?, + None => { + warn!("Device '{}' not found, using default device", name); + OutputStreamBuilder::from_default_device()? } } } else { @@ -128,15 +219,17 @@ fn play_audio_file( OutputStreamBuilder::from_default_device()? }; - let stream_handle = stream_builder.open_stream()?; - let mixer = stream_handle.mixer(); + Ok(stream_builder.open_stream()?) +} +fn play_on_stream( + stream: &rodio::OutputStream, + path: &Path, + volume: f32, +) -> Result<(), Box> { let file = File::open(path)?; - let buf_reader = BufReader::new(file); - - let sink = rodio::play(mixer, buf_reader)?; + let sink = rodio::play(stream.mixer(), BufReader::new(file))?; sink.set_volume(volume); sink.sleep_until_end(); - Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31c0cae77c..254f9157f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -182,6 +182,11 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(history_manager.clone()); app_handle.manage(tray::CurrentTrayIconState::new()); + // Pre-warm the audio feedback output stream while no transcription is + // running: opening it lazily on the transcription path races concurrent + // WASAPI session state (see audio_feedback.rs). + audio_feedback::init(app_handle); + // Note: Shortcuts are NOT initialized here. // The frontend is responsible for calling the `initialize_shortcuts` command // after permissions are confirmed (on macOS) or after onboarding completes. From 3316ef50ff551722062d6d90c196f88fe922958c Mon Sep 17 00:00:00 2001 From: Alexandr Klimok Date: Tue, 14 Jul 2026 09:14:01 +0400 Subject: [PATCH 2/5] fix: survive a wedged audio stack end to end (bounded waits everywhere) Field testing of the persistent-stream fix surfaced the second half of the failure chain. When the output stream turns into a zombie after a device state change (frozen audio clock, no error -- observed with a wireless headset), the chime worker used to wait on it forever; the wedged WASAPI state then stopped capture callbacks, and the mic worker only serviced commands on chunk arrival -- so Stop sat unread and the pipeline hung "busy" forever even though the chime path was already fire-and-forget. - mic worker: service commands on a 250ms tick even when the capture callback delivers nothing (wedged engine, dead device) - recorder.stop(): bounded 5s wait -- losing one utterance is recoverable, a forever-busy pipeline is not - recorder.close(): bounded join via reaper thread; a stream drop that blocks on a wedged engine parks a disposable thread, not the caller - chime playback: bounded 5s wait instead of sleep_until_end(); a stalled sink marks the stream as zombie - zombie streams are dropped on throwaway threads and recreated fresh Worst-case degradation under any WASAPI misbehavior is now one lost chime or one lost utterance, never a stuck app. Co-Authored-By: Claude Fable 5 --- src-tauri/src/audio_feedback.rs | 37 ++++++++++++++++--- src-tauri/src/audio_toolkit/audio/recorder.rs | 33 ++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index 1bd85098ba..97a189c4e9 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -141,6 +141,11 @@ fn wait_for_play(settings: &AppSettings, path: PathBuf) { } } +/// If a chime hasn't finished within this bound, its output stream is +/// considered a zombie (frozen audio clock after a device state change — +/// observed with wireless headsets) and gets scrapped. +const PLAYBACK_STALL_TIMEOUT: Duration = Duration::from_secs(5); + fn playback_worker(rx: mpsc::Receiver) { // The output stream is created once and kept open across transcriptions. // Recreating it per chime is what raced concurrent WASAPI session state @@ -161,11 +166,11 @@ fn playback_worker(rx: mpsc::Receiver) { if let Some((_, stream)) = ensure_stream(&mut cached, device) { if let Err(e) = play_on_stream(stream, &path, volume) { error!( - "Failed to play sound '{}': {}; dropping cached output stream", + "Failed to play sound '{}': {}; scrapping output stream", path.display(), e ); - cached = None; + scrap_stream(&mut cached); } } if let Some(done) = done { @@ -176,14 +181,22 @@ fn playback_worker(rx: mpsc::Receiver) { } } +/// Dispose of the cached stream on a throwaway thread. Dropping a cpal +/// stream whose device wedged can block indefinitely — that must park a +/// disposable thread, never this worker. +fn scrap_stream(cached: &mut Option<(Option, rodio::OutputStream)>) { + if let Some((_, stream)) = cached.take() { + thread::spawn(move || drop(stream)); + } +} + fn ensure_stream( cached: &mut Option<(Option, rodio::OutputStream)>, device: Option, ) -> Option<&(Option, rodio::OutputStream)> { let stale = cached.as_ref().map(|(d, _)| d != &device).unwrap_or(true); if stale { - // Drop any previous stream before opening the replacement. - *cached = None; + scrap_stream(cached); match create_stream(device.as_deref()) { Ok(stream) => *cached = Some((device, stream)), Err(e) => error!("Failed to open audio feedback output stream: {}", e), @@ -230,6 +243,20 @@ fn play_on_stream( let file = File::open(path)?; let sink = rodio::play(stream.mixer(), BufReader::new(file))?; sink.set_volume(volume); - sink.sleep_until_end(); + // Bounded wait instead of sleep_until_end(): a stream whose device + // changed state underneath it stops consuming samples without erroring, + // and an unbounded wait would wedge the worker on it forever. + let started = std::time::Instant::now(); + while !sink.empty() { + if started.elapsed() > PLAYBACK_STALL_TIMEOUT { + sink.stop(); + return Err(format!( + "playback did not finish within {:?} (zombie output stream?)", + PLAYBACK_STALL_TIMEOUT + ) + .into()); + } + thread::sleep(Duration::from_millis(25)); + } Ok(()) } diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 82297cb672..f6edbfaa06 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -328,7 +328,10 @@ impl AudioRecorder { if let Some(tx) = &self.cmd_tx { tx.send(Cmd::Stop(resp_tx))?; } - Ok(resp_rx.recv()?) // wait for the samples + // Bounded wait: if the audio stack is wedged (e.g. a WASAPI hang took + // the capture callback down), the worker may never reply. Losing one + // utterance is recoverable; a forever-busy pipeline is not. + Ok(resp_rx.recv_timeout(Duration::from_secs(5))?) } pub fn close(&mut self) -> Result<(), Box> { @@ -336,7 +339,19 @@ impl AudioRecorder { let _ = tx.send(Cmd::Shutdown); } if let Some(h) = self.worker_handle.take() { - let _ = h.join(); + // Bounded join via a reaper thread: dropping the cpal stream on a + // wedged audio engine can block indefinitely, and that must not + // freeze the caller. A detached parked thread is the lesser evil. + let (done_tx, done_rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = h.join(); + let _ = done_tx.send(()); + }); + if done_rx.recv_timeout(Duration::from_secs(3)).is_err() { + log::warn!( + "Mic worker did not shut down within 3s; detaching it (audio stack wedged?)" + ); + } } self.device = None; Ok(()) @@ -597,13 +612,21 @@ fn run_consumer( } } - // Runs until the stream closes and `recv` returns `Err`. - while let Ok(chunk) = sample_rx.recv() { + // Runs until the stream closes and `recv` returns `Err(Disconnected)`. + // The timeout matters: commands must stay serviceable even when the + // capture callback stops delivering chunks (wedged audio engine, dead + // device) — otherwise a Stop/Shutdown sits unread and the caller hangs. + loop { + let chunk = match sample_rx.recv_timeout(Duration::from_millis(250)) { + Ok(chunk) => Some(chunk), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => return, + }; // Handle pending commands BEFORE the in-flight chunk so a Start // captures it. Commands used to be polled after processing, which // silently dropped one buffer period of audio (~10ms built-in, up to // ~100ms on Bluetooth) at every recording start. - let mut pending = Some(chunk); + let mut pending = chunk; while let Ok(cmd) = cmd_rx.try_recv() { match cmd { Cmd::Start(policy, sent_at) => { From 0aa7b6f45ba191b81ac4ea5f1c1e33f15373d962 Mon Sep 17 00:00:00 2001 From: Alexandr Klimok Date: Fri, 17 Jul 2026 23:00:18 +0400 Subject: [PATCH 3/5] fix: make the persistent "Default" chime stream follow OS default changes The persistent output stream keeps playing to the device it was opened on, so after the user switches the Windows default output (headphones <-> speakers) chimes kept going to the old device. Before each use of a Default-selection stream, resolve what the OS default currently points to (a cheap device-name query, not a stream creation) and recreate the stream when it moved. Explicit device selections are unaffected. Co-Authored-By: Claude Fable 5 --- src-tauri/src/audio_feedback.rs | 64 +++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index 97a189c4e9..29bc4da51a 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -146,11 +146,23 @@ fn wait_for_play(settings: &AppSettings, path: PathBuf) { /// observed with wireless headsets) and gets scrapped. const PLAYBACK_STALL_TIMEOUT: Duration = Duration::from_secs(5); +struct CachedStream { + /// The settings selection this stream was created for (None / "Default" + /// / explicit device name). + selection: Option, + /// For a default selection: the concrete device the OS default resolved + /// to at creation time. The persistent stream does not follow default + /// changes by itself, so this is compared against the current default + /// before each use. + default_name: Option, + stream: rodio::OutputStream, +} + fn playback_worker(rx: mpsc::Receiver) { // The output stream is created once and kept open across transcriptions. // Recreating it per chime is what raced concurrent WASAPI session state // and could deadlock the whole audio stack, mic stream shutdown included. - let mut cached: Option<(Option, rodio::OutputStream)> = None; + let mut cached: Option = None; while let Ok(req) = rx.recv() { match req { @@ -163,8 +175,8 @@ fn playback_worker(rx: mpsc::Receiver) { volume, done, } => { - if let Some((_, stream)) = ensure_stream(&mut cached, device) { - if let Err(e) = play_on_stream(stream, &path, volume) { + if let Some(c) = ensure_stream(&mut cached, device) { + if let Err(e) = play_on_stream(&c.stream, &path, volume) { error!( "Failed to play sound '{}': {}; scrapping output stream", path.display(), @@ -184,21 +196,53 @@ fn playback_worker(rx: mpsc::Receiver) { /// Dispose of the cached stream on a throwaway thread. Dropping a cpal /// stream whose device wedged can block indefinitely — that must park a /// disposable thread, never this worker. -fn scrap_stream(cached: &mut Option<(Option, rodio::OutputStream)>) { - if let Some((_, stream)) = cached.take() { - thread::spawn(move || drop(stream)); +fn scrap_stream(cached: &mut Option) { + if let Some(c) = cached.take() { + thread::spawn(move || drop(c.stream)); + } +} + +fn is_default_selection(device: &Option) -> bool { + match device { + None => true, + Some(name) => name == "Default", } } +/// Name of the device the OS default output currently resolves to. A cheap +/// query compared to stream creation; runs on the worker, so a wedged audio +/// stack costs a chime, not the pipeline. +fn current_default_name() -> Option { + let host = crate::audio_toolkit::get_cpal_host(); + host.default_output_device().and_then(|d| d.name().ok()) +} + fn ensure_stream( - cached: &mut Option<(Option, rodio::OutputStream)>, + cached: &mut Option, device: Option, -) -> Option<&(Option, rodio::OutputStream)> { - let stale = cached.as_ref().map(|(d, _)| d != &device).unwrap_or(true); +) -> Option<&CachedStream> { + // A "Default" stream must follow the OS default: the persistent stream + // keeps playing to the device it was opened on, so compare what the + // default resolves to now against what it resolved to at creation. + let default_name = if is_default_selection(&device) { + current_default_name() + } else { + None + }; + let stale = cached.as_ref().is_none_or(|c| { + c.selection != device + || (is_default_selection(&device) && c.default_name != default_name) + }); if stale { scrap_stream(cached); match create_stream(device.as_deref()) { - Ok(stream) => *cached = Some((device, stream)), + Ok(stream) => { + *cached = Some(CachedStream { + selection: device, + default_name, + stream, + }) + } Err(e) => error!("Failed to open audio feedback output stream: {}", e), } } From ad2ad04aee0e5a4ed2b3f5720074a5ea1d9d0f71 Mon Sep 17 00:00:00 2001 From: Alexandr Klimok Date: Sat, 18 Jul 2026 16:05:23 +0400 Subject: [PATCH 4/5] style: apply cargo fmt Co-Authored-By: Claude Fable 5 --- src-tauri/src/audio_feedback.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index 29bc4da51a..54b74d2c52 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -55,9 +55,7 @@ fn get_sound_base_dir(settings: &AppSettings) -> tauri::path::BaseDirectory { enum Request { /// Ensure the output stream for `device` exists (startup pre-warm). - Warm { - device: Option, - }, + Warm { device: Option }, Play { path: PathBuf, device: Option, @@ -230,8 +228,7 @@ fn ensure_stream( None }; let stale = cached.as_ref().is_none_or(|c| { - c.selection != device - || (is_default_selection(&device) && c.default_name != default_name) + c.selection != device || (is_default_selection(&device) && c.default_name != default_name) }); if stale { scrap_stream(cached); From 5a806474f8d9b1121a0fed9c224bf8e3ce763481 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 20 Jul 2026 15:21:56 +0800 Subject: [PATCH 5/5] remove audio feedback changes which didnt compile --- src-tauri/src/audio_feedback.rs | 257 ++++++-------------------------- src-tauri/src/lib.rs | 5 - 2 files changed, 48 insertions(+), 214 deletions(-) diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs index 54b74d2c52..ef759a1384 100644 --- a/src-tauri/src/audio_feedback.rs +++ b/src-tauri/src/audio_feedback.rs @@ -6,10 +6,7 @@ use rodio::OutputStreamBuilder; use std::fs::File; use std::io::BufReader; use std::path::{Path, PathBuf}; -use std::sync::mpsc; -use std::sync::OnceLock; use std::thread; -use std::time::Duration; use tauri::{AppHandle, Manager}; pub enum SoundType { @@ -17,11 +14,6 @@ pub enum SoundType { Stop, } -/// How long a caller that needs the chime to finish (to sequence muting -/// after it) is allowed to wait. If the audio stack is wedged, callers -/// proceed without sound instead of hanging the transcription pipeline. -const BLOCKING_PLAY_TIMEOUT: Duration = Duration::from_secs(3); - fn resolve_sound_path( app: &AppHandle, settings: &AppSettings, @@ -53,52 +45,13 @@ fn get_sound_base_dir(settings: &AppSettings) -> tauri::path::BaseDirectory { } } -enum Request { - /// Ensure the output stream for `device` exists (startup pre-warm). - Warm { device: Option }, - Play { - path: PathBuf, - device: Option, - volume: f32, - done: Option>, - }, -} - -static PLAYER: OnceLock> = OnceLock::new(); - -fn player() -> &'static mpsc::Sender { - PLAYER.get_or_init(|| { - let (tx, rx) = mpsc::channel(); - thread::Builder::new() - .name("audio-feedback".into()) - .spawn(move || playback_worker(rx)) - .expect("failed to spawn audio feedback thread"); - tx - }) -} - -/// Pre-warm the output stream at startup, while no transcription is running. -/// Opening the stream is the WASAPI call that can wedge when it races other -/// audio session activity, taking the rest of the audio stack (including the -/// microphone stream shutdown on the transcription path) down with it — so it -/// happens once here and on device change, never per transcription. -pub fn init(app: &AppHandle) { - let settings = settings::get_settings(app); - if !settings.audio_feedback { - return; - } - let _ = player().send(Request::Warm { - device: settings.selected_output_device.clone(), - }); -} - pub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) { let settings = settings::get_settings(app); if !settings.audio_feedback { return; } if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - send_play(&settings, path, None); + play_sound_async(app, path); } } @@ -108,164 +61,66 @@ pub fn play_feedback_sound_blocking(app: &AppHandle, sound_type: SoundType) { return; } if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - wait_for_play(&settings, path); + play_sound_blocking(app, &path); } } pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) { let settings = settings::get_settings(app); if let Some(path) = resolve_sound_path(app, &settings, sound_type) { - wait_for_play(&settings, path); + play_sound_blocking(app, &path); } } -fn send_play(settings: &AppSettings, path: PathBuf, done: Option>) { - let _ = player().send(Request::Play { - path, - device: settings.selected_output_device.clone(), - volume: settings.audio_feedback_volume, - done, +fn play_sound_async(app: &AppHandle, path: PathBuf) { + let app_handle = app.clone(); + thread::spawn(move || { + if let Err(e) = play_sound_at_path(&app_handle, path.as_path()) { + error!("Failed to play sound '{}': {}", path.display(), e); + } }); } -fn wait_for_play(settings: &AppSettings, path: PathBuf) { - let (tx, rx) = mpsc::channel(); - send_play(settings, path, Some(tx)); - if rx.recv_timeout(BLOCKING_PLAY_TIMEOUT).is_err() { - warn!( - "Audio feedback did not finish within {:?}; continuing without it", - BLOCKING_PLAY_TIMEOUT - ); +fn play_sound_blocking(app: &AppHandle, path: &Path) { + if let Err(e) = play_sound_at_path(app, path) { + error!("Failed to play sound '{}': {}", path.display(), e); } } -/// If a chime hasn't finished within this bound, its output stream is -/// considered a zombie (frozen audio clock after a device state change — -/// observed with wireless headsets) and gets scrapped. -const PLAYBACK_STALL_TIMEOUT: Duration = Duration::from_secs(5); - -struct CachedStream { - /// The settings selection this stream was created for (None / "Default" - /// / explicit device name). - selection: Option, - /// For a default selection: the concrete device the OS default resolved - /// to at creation time. The persistent stream does not follow default - /// changes by itself, so this is compared against the current default - /// before each use. - default_name: Option, - stream: rodio::OutputStream, +fn play_sound_at_path(app: &AppHandle, path: &Path) -> Result<(), Box> { + let settings = settings::get_settings(app); + let volume = settings.audio_feedback_volume; + let selected_device = settings.selected_output_device.clone(); + play_audio_file(path, selected_device, volume) } -fn playback_worker(rx: mpsc::Receiver) { - // The output stream is created once and kept open across transcriptions. - // Recreating it per chime is what raced concurrent WASAPI session state - // and could deadlock the whole audio stack, mic stream shutdown included. - let mut cached: Option = None; - - while let Ok(req) = rx.recv() { - match req { - Request::Warm { device } => { - ensure_stream(&mut cached, device); - } - Request::Play { - path, - device, - volume, - done, - } => { - if let Some(c) = ensure_stream(&mut cached, device) { - if let Err(e) = play_on_stream(&c.stream, &path, volume) { - error!( - "Failed to play sound '{}': {}; scrapping output stream", - path.display(), - e - ); - scrap_stream(&mut cached); - } - } - if let Some(done) = done { - let _ = done.send(()); +fn play_audio_file( + path: &std::path::Path, + selected_device: Option, + volume: f32, +) -> Result<(), Box> { + let stream_builder = if let Some(device_name) = selected_device { + if device_name == "Default" { + debug!("Using default device"); + OutputStreamBuilder::from_default_device()? + } else { + let host = crate::audio_toolkit::get_cpal_host(); + let devices = host.output_devices()?; + + let mut found_device = None; + for device in devices { + if device.name()? == device_name { + found_device = Some(device); + break; } } - } - } -} - -/// Dispose of the cached stream on a throwaway thread. Dropping a cpal -/// stream whose device wedged can block indefinitely — that must park a -/// disposable thread, never this worker. -fn scrap_stream(cached: &mut Option) { - if let Some(c) = cached.take() { - thread::spawn(move || drop(c.stream)); - } -} - -fn is_default_selection(device: &Option) -> bool { - match device { - None => true, - Some(name) => name == "Default", - } -} - -/// Name of the device the OS default output currently resolves to. A cheap -/// query compared to stream creation; runs on the worker, so a wedged audio -/// stack costs a chime, not the pipeline. -fn current_default_name() -> Option { - let host = crate::audio_toolkit::get_cpal_host(); - host.default_output_device().and_then(|d| d.name().ok()) -} - -fn ensure_stream( - cached: &mut Option, - device: Option, -) -> Option<&CachedStream> { - // A "Default" stream must follow the OS default: the persistent stream - // keeps playing to the device it was opened on, so compare what the - // default resolves to now against what it resolved to at creation. - let default_name = if is_default_selection(&device) { - current_default_name() - } else { - None - }; - let stale = cached.as_ref().is_none_or(|c| { - c.selection != device || (is_default_selection(&device) && c.default_name != default_name) - }); - if stale { - scrap_stream(cached); - match create_stream(device.as_deref()) { - Ok(stream) => { - *cached = Some(CachedStream { - selection: device, - default_name, - stream, - }) - } - Err(e) => error!("Failed to open audio feedback output stream: {}", e), - } - } - cached.as_ref() -} - -fn create_stream( - device_name: Option<&str>, -) -> Result> { - let stream_builder = if let Some(name) = device_name.filter(|n| *n != "Default") { - let host = crate::audio_toolkit::get_cpal_host(); - let devices = host.output_devices()?; - - let mut found_device = None; - for device in devices { - if device.name()? == name { - found_device = Some(device); - break; - } - } - match found_device { - Some(device) => OutputStreamBuilder::from_device(device)?, - None => { - warn!("Device '{}' not found, using default device", name); - OutputStreamBuilder::from_default_device()? + match found_device { + Some(device) => OutputStreamBuilder::from_device(device)?, + None => { + warn!("Device '{}' not found, using default device", device_name); + OutputStreamBuilder::from_default_device()? + } } } } else { @@ -273,31 +128,15 @@ fn create_stream( OutputStreamBuilder::from_default_device()? }; - Ok(stream_builder.open_stream()?) -} + let stream_handle = stream_builder.open_stream()?; + let mixer = stream_handle.mixer(); -fn play_on_stream( - stream: &rodio::OutputStream, - path: &Path, - volume: f32, -) -> Result<(), Box> { let file = File::open(path)?; - let sink = rodio::play(stream.mixer(), BufReader::new(file))?; + let buf_reader = BufReader::new(file); + + let sink = rodio::play(mixer, buf_reader)?; sink.set_volume(volume); - // Bounded wait instead of sleep_until_end(): a stream whose device - // changed state underneath it stops consuming samples without erroring, - // and an unbounded wait would wedge the worker on it forever. - let started = std::time::Instant::now(); - while !sink.empty() { - if started.elapsed() > PLAYBACK_STALL_TIMEOUT { - sink.stop(); - return Err(format!( - "playback did not finish within {:?} (zombie output stream?)", - PLAYBACK_STALL_TIMEOUT - ) - .into()); - } - thread::sleep(Duration::from_millis(25)); - } + sink.sleep_until_end(); + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 254f9157f7..31c0cae77c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -182,11 +182,6 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(history_manager.clone()); app_handle.manage(tray::CurrentTrayIconState::new()); - // Pre-warm the audio feedback output stream while no transcription is - // running: opening it lazily on the transcription path races concurrent - // WASAPI session state (see audio_feedback.rs). - audio_feedback::init(app_handle); - // Note: Shortcuts are NOT initialized here. // The frontend is responsible for calling the `initialize_shortcuts` command // after permissions are confirmed (on macOS) or after onboarding completes.