fix(dictation): scope STT shutdown drain to dictation + stop PTT on blur

Two Codex P2 findings on PR #1511:

- stt_engine.rs: the shutdown drain + final flush ran for every SttEngine,
  including the huddle wrapper. Leaving a huddle / disabling transcription
  calls shutdown() then drops the pipeline (Drop joins the worker), so
  draining the queue and decoding the trailing buffer could block huddle
  teardown even though the huddle generation guard discards the transcript.
  Gate the drain + final flush behind a new SttEngineConfig.flush_on_shutdown
  flag: true for dictation (preserves the last-words fix), false for huddle
  (fast no-final-flush teardown, as before).

- AppShell.tsx: push-to-talk dictation only stopped on keyup, so if the
  WebView lost focus before \u2318D was released (Cmd-Tab, clicking another app,
  OS focus change), the key-up event never fired and the mic kept recording.
  Add blur + visibilitychange handlers that dispatch buzz:dictation-key-up
  when the key is still held.
This commit is contained in:
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
2026-07-11 16:20:30 +01:00
parent 8e046c0ed3
commit 18f7a373d0
4 changed files with 42 additions and 0 deletions
+1
View File
@@ -79,6 +79,7 @@ pub async fn start_dictation(state: State<'_, AppState>) -> Result<u64, String>
tts_active: None,
tts_cancel: None,
ptt_active: None,
flush_on_shutdown: true,
};
let (engine, text_rx) = SttEngine::new(config)?;
+1
View File
@@ -71,6 +71,7 @@ impl SttPipeline {
tts_active: Some(tts_active),
tts_cancel,
ptt_active,
flush_on_shutdown: false,
};
let (engine, text_rx) = SttEngine::new(config)?;
+19
View File
@@ -72,6 +72,15 @@ pub struct SttEngineConfig {
/// Optional: push-to-talk flag — when `Some`, speech is only accumulated
/// while the flag is true.
pub ptt_active: Option<Arc<AtomicBool>>,
/// When `true`, the worker drains any audio still queued at shutdown and
/// transcribes the trailing speech buffer before exiting. Dictation needs
/// this so the last words the user spoke before releasing the key still
/// land in the transcript. Set to `false` for huddle: leaving a huddle (or
/// disabling transcription) calls `shutdown()` then drops the pipeline,
/// whose `Drop` joins this worker — running the full drain + final decode
/// there would block huddle teardown, and the huddle generation guard
/// discards any transcript produced during teardown anyway.
pub flush_on_shutdown: bool,
}
// ── Public engine handle ──────────────────────────────────────────────────────
@@ -265,6 +274,7 @@ fn stt_worker(
// ── 5. Main loop ──────────────────────────────────────────────────────────
let has_tts = config.tts_active.is_some();
let flush_on_shutdown = config.flush_on_shutdown;
let tts_active_flag = config
.tts_active
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
@@ -351,6 +361,15 @@ fn stt_worker(
// enqueued batch, so the tail of speech would never reach `speech_buf` and
// the final flush below would only transcribe older audio — dropping the
// last words. Process everything still in the channel before flushing.
//
// Only dictation opts into this (`flush_on_shutdown`). Huddle shutdown must
// stay fast: its `Drop` joins this worker, so draining the queue + decoding
// the final buffer here would block huddle teardown, and any transcript
// produced during teardown is discarded by the huddle generation guard.
if !flush_on_shutdown {
return;
}
while let Ok(bytes) = audio_rx.try_recv() {
let samples_48k = bytes_to_f32(&bytes);
input_buf_48k.extend_from_slice(&samples_48k);
+21
View File
@@ -621,11 +621,32 @@ export function AppShell() {
}
}
// If the WebView loses focus (Cmd-Tab, clicking another app, an OS focus
// change) or is hidden before ⌘D is released, the keyup never fires and the
// mic would keep recording. Release the push-to-talk key on blur/hide so
// the recording stops instead of running unattended.
function releaseDictationKey() {
if (dictationKeyHeld) {
dictationKeyHeld = false;
window.dispatchEvent(new CustomEvent("buzz:dictation-key-up"));
}
}
function handleVisibilityChange() {
if (document.hidden) {
releaseDictationKey();
}
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", releaseDictationKey);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", releaseDictationKey);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [
handleOpenBrowseChannels,