Bug Description
On Windows, with Audio Feedback enabled, the transcription pipeline intermittently hangs forever after the stop hotkey: the Transcribing... overlay never resolves, Cancel does not help, further presses log Ignoring press for 'transcribe': pipeline busy, and only restarting Handy recovers. Before root-causing this I hit the hang every 2–30 transcriptions; disabling Audio Feedback was the only reliable workaround. Reproduced on v0.8.3 and the relevant code is unchanged in v0.9.2.
After building from source, instrumenting, and field-testing fixes for several days, this turns out to be a chain of two independent defects:
Defect A — audio_feedback opens a fresh WASAPI output stream on every chime
src-tauri/src/audio_feedback.rs (play_audio_file) calls OutputStreamBuilder::from_default_device() — or, for an explicit device, host.output_devices() enumeration — on every feedback sound, i.e. twice per transcription, concurrently with other WASAPI session activity (music playing, the app's own mic stream being stopped on the same hot path). This per-chime stream creation is fragile: after a device state change (observed with a wireless headset; likely also sleep/wake, cf. #1213) the stream can stall with a frozen audio clock and no error — sleep_until_end() then never returns and the playback thread is wedged inside the audio stack forever.
Chime playback is already fire-and-forget on the stop path (a detached thread), so defect A alone does not hang the pipeline — which is why the hang is intermittent and survived the async refactor. The pipeline dies because of:
Defect B — the mic worker services commands only while capture callbacks deliver audio
In src-tauri/src/audio_toolkit/audio/recorder.rs the worker's main loop is:
while let Ok(chunk) = sample_rx.recv() {
// commands (Cmd::Start / Cmd::Stop / Cmd::Shutdown) are polled
// only after a chunk arrives
and both stop() (resp_rx.recv()) and close() (worker_handle.join()) wait unbounded.
When the audio engine is disturbed by the wedged render stream from defect A, the capture callback stops delivering chunks. The worker then sleeps in sample_rx.recv() forever, Cmd::Stop is never read, the async transcription task blocks inside stop_recording, and the pipeline stays busy until the process is killed. Any other condition that silences the capture callback (dead device, engine stall — cf. #1228, #1349 on PipeWire) reaches the same dead end through defect B, with or without audio feedback.
This chain is directly visible in the logs below: on a build where chime playback already had a bounded timeout, the wedged chime thread produced 90 seconds of contained "audio feedback did not finish" warnings (six transcriptions kept working, silently), after which a stop request hung exactly at the mic-stream stop and never returned.
Fixes (implemented and field-tested)
I have three fixes running on my machine as a patched v0.9.2 build. Happy to submit any/all as PRs.
- Persistent chime output stream (defect A): a dedicated long-lived playback worker opens the output stream once — pre-warmed at startup, while no transcription is running — and reuses it; it is recreated only on device change or playback error. Blocking callers (start-chime → mute sequencing, test sound) wait with a bounded timeout. Per-transcription WASAPI stream creation is gone entirely.
- Bounded waits in the recorder (defect B): the worker's main loop uses
recv_timeout(250ms) so commands are serviced even when the capture callback delivers nothing; stop() waits at most 5 s (losing one utterance is recoverable, a forever-busy pipeline is not); close() joins via a reaper thread with a 3 s bound. Streams suspected of being wedged are dropped on throwaway threads, never on the worker.
- Default-device following: since the persistent stream no longer re-resolves the default on every play, a "Default" stream re-checks what the OS default resolves to before each use (a cheap device-name query, not a stream creation) and recreates itself when the user switches headphones ↔ speakers.
Field validation: before the fixes the hang occurred every 2–30 transcriptions with Audio Feedback on. With the fixes: several days of daily use (hundreds of transcriptions, GigaAM v3 ONNX and Whisper Large v3 on the Vulkan backend), zero hangs. Notably, one real audio-stack stall did occur during testing — it degraded to a few missing chimes and a WARN in the log, exactly as designed, and recovered without restart. Worst-case degradation is now one lost chime or one lost utterance, never a stuck app.
System Information
App Version: v0.9.2 (originally reproduced on v0.8.3; the affected code paths are identical)
Operating System: Windows 10 Home 10.0.19045
CPU: AMD Ryzen 7 9800X3D
GPU: NVIDIA RTX 5070
Audio: USB fifine microphone (capture); ROG Theta wireless headset / speakers (render, both as OS default and as explicit selection — the hang reproduced in both configurations, explicit device only reduced the frequency)
Logs
Original hang, stock v0.8.3 (handy.log, sanitized). The async task reaches the chime WAV probe and then produces no further entries — no Microphone stream stopped, no inference, nothing:
[18:59:15][handy_app_lib::actions][DEBUG] TranscribeAction::stop called for binding: transcribe
[18:59:15][handy_app_lib::actions][DEBUG] TranscribeAction::stop completed in 6.1668ms
[18:59:15][handy_app_lib::actions][DEBUG] Starting async transcription task for binding: transcribe
[18:59:16][symphonia_core::probe][DEBUG] found the format marker [52, 49, 46, 46] @ 0+2 bytes.
[18:59:22][handy_app_lib::shortcut::handy_keys][DEBUG] handy-keys event: binding=transcribe, state=Pressed
[18:59:22][handy_app_lib::transcription_coordinator][DEBUG] Ignoring press for 'transcribe': pipeline busy
The two-defect chain, captured on a build where chime playback already had a bounded blocking timeout (fix 1 partially applied). The wedged chime thread is contained for ~90 s — six transcriptions complete normally, only the sound is missing — then a stop request hangs at the mic-stream stop (defect B) and never completes:
[04:57:25][handy_app_lib::audio_feedback][WARN] Audio feedback did not finish within 3s; continuing without it
[04:57:28][handy_app_lib::managers::transcription][INFO] Transcription completed in 0.04s ... <- pipeline still fine
[04:57:37][handy_app_lib::audio_feedback][WARN] Audio feedback did not finish within 3s; continuing without it
[04:57:38][handy_app_lib::managers::transcription][INFO] Transcription completed in 0.06s ... <- still fine
[04:58:49][handy_app_lib::audio_feedback][WARN] Audio feedback did not finish within 3s; continuing without it
[04:58:54][handy_app_lib::actions][DEBUG] TranscribeAction::stop called for binding: transcribe
[04:58:54][handy_app_lib::actions][DEBUG] Starting async transcription task for binding: transcribe
<- nothing ever follows: no "Recording stopped and samples retrieved"
[04:59:26][handy_app_lib::utils][INFO] Initiating operation cancellation...
[04:59:26][handy_app_lib::managers::audio][DEBUG] Cancellation requested while recording is stopping
[04:59:30][handy_app_lib::transcription_coordinator][DEBUG] Ignoring press for 'transcribe': pipeline busy
Related issues
If useful, this issue can serve as the consolidated root-cause report for the family above.
Disclosure: the analysis and patches were developed with AI assistance (Claude Code) and were built, instrumented and field-validated by me on the machine described above.
Bug Description
On Windows, with Audio Feedback enabled, the transcription pipeline intermittently hangs forever after the stop hotkey: the
Transcribing...overlay never resolves, Cancel does not help, further presses logIgnoring press for 'transcribe': pipeline busy, and only restarting Handy recovers. Before root-causing this I hit the hang every 2–30 transcriptions; disabling Audio Feedback was the only reliable workaround. Reproduced on v0.8.3 and the relevant code is unchanged in v0.9.2.After building from source, instrumenting, and field-testing fixes for several days, this turns out to be a chain of two independent defects:
Defect A — audio_feedback opens a fresh WASAPI output stream on every chime
src-tauri/src/audio_feedback.rs(play_audio_file) callsOutputStreamBuilder::from_default_device()— or, for an explicit device,host.output_devices()enumeration — on every feedback sound, i.e. twice per transcription, concurrently with other WASAPI session activity (music playing, the app's own mic stream being stopped on the same hot path). This per-chime stream creation is fragile: after a device state change (observed with a wireless headset; likely also sleep/wake, cf. #1213) the stream can stall with a frozen audio clock and no error —sleep_until_end()then never returns and the playback thread is wedged inside the audio stack forever.Chime playback is already fire-and-forget on the stop path (a detached thread), so defect A alone does not hang the pipeline — which is why the hang is intermittent and survived the async refactor. The pipeline dies because of:
Defect B — the mic worker services commands only while capture callbacks deliver audio
In
src-tauri/src/audio_toolkit/audio/recorder.rsthe worker's main loop is:and both
stop()(resp_rx.recv()) andclose()(worker_handle.join()) wait unbounded.When the audio engine is disturbed by the wedged render stream from defect A, the capture callback stops delivering chunks. The worker then sleeps in
sample_rx.recv()forever,Cmd::Stopis never read, the async transcription task blocks insidestop_recording, and the pipeline staysbusyuntil the process is killed. Any other condition that silences the capture callback (dead device, engine stall — cf. #1228, #1349 on PipeWire) reaches the same dead end through defect B, with or without audio feedback.This chain is directly visible in the logs below: on a build where chime playback already had a bounded timeout, the wedged chime thread produced 90 seconds of contained "audio feedback did not finish" warnings (six transcriptions kept working, silently), after which a stop request hung exactly at the mic-stream stop and never returned.
Fixes (implemented and field-tested)
I have three fixes running on my machine as a patched v0.9.2 build. Happy to submit any/all as PRs.
recv_timeout(250ms)so commands are serviced even when the capture callback delivers nothing;stop()waits at most 5 s (losing one utterance is recoverable, a forever-busy pipeline is not);close()joins via a reaper thread with a 3 s bound. Streams suspected of being wedged are dropped on throwaway threads, never on the worker.Field validation: before the fixes the hang occurred every 2–30 transcriptions with Audio Feedback on. With the fixes: several days of daily use (hundreds of transcriptions, GigaAM v3 ONNX and Whisper Large v3 on the Vulkan backend), zero hangs. Notably, one real audio-stack stall did occur during testing — it degraded to a few missing chimes and a WARN in the log, exactly as designed, and recovered without restart. Worst-case degradation is now one lost chime or one lost utterance, never a stuck app.
System Information
App Version: v0.9.2 (originally reproduced on v0.8.3; the affected code paths are identical)
Operating System: Windows 10 Home 10.0.19045
CPU: AMD Ryzen 7 9800X3D
GPU: NVIDIA RTX 5070
Audio: USB fifine microphone (capture); ROG Theta wireless headset / speakers (render, both as OS default and as explicit selection — the hang reproduced in both configurations, explicit device only reduced the frequency)
Logs
Original hang, stock v0.8.3 (
handy.log, sanitized). The async task reaches the chime WAV probe and then produces no further entries — noMicrophone stream stopped, no inference, nothing:The two-defect chain, captured on a build where chime playback already had a bounded blocking timeout (fix 1 partially applied). The wedged chime thread is contained for ~90 s — six transcriptions complete normally, only the sound is missing — then a stop request hangs at the mic-stream stop (defect B) and never completes:
Related issues
If useful, this issue can serve as the consolidated root-cause report for the family above.
Disclosure: the analysis and patches were developed with AI assistance (Claude Code) and were built, instrumented and field-validated by me on the machine described above.