From c657b225d60032e310f000013222d978bc6a35f3 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:07:14 +0000 Subject: [PATCH] fix(speech): treat aborted as benign; only abort when listening (#197) Port of the speech hunks from open PR #182, standalone. isBenignSpeechError classifies 'no-speech' and 'aborted' as benign (aborted is the documented outcome of abort(); events are module-global, so another screen's cleanup abort() used to surface a phantom 'Voice input failed' alert on the parent session screen). Unmount cleanup now only calls abort() if this hook instance actually started listening, tracked via a listeningRef synced in useEffect. Fixes #197 --- src/lib/speech-errors.ts | 14 ++++++++++++++ src/lib/speech.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 src/lib/speech-errors.ts diff --git a/src/lib/speech-errors.ts b/src/lib/speech-errors.ts new file mode 100644 index 00000000..b08974f6 --- /dev/null +++ b/src/lib/speech-errors.ts @@ -0,0 +1,14 @@ +/** + * Which speech-recognition "error" events are actually errors. + * + * The Android module fires an error event for outcomes that are not + * failures: "no-speech" (the user simply said nothing) and "aborted" + * (the documented outcome of calling abort()). Events are module-global, + * so a screen unmounting and calling abort() in cleanup broadcasts + * "aborted" to every OTHER mounted session screen's listener. That's how + * backing out of a subagent view produced a "Voice input failed" alert + * on the parent screen with the mic never touched. + */ +export function isBenignSpeechError(code: string | undefined): boolean { + return code === "no-speech" || code === "aborted" +} diff --git a/src/lib/speech.ts b/src/lib/speech.ts index 597fccfc..68c6453f 100644 --- a/src/lib/speech.ts +++ b/src/lib/speech.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react" import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition" +import { isBenignSpeechError } from "./speech-errors" interface SpeechState { listening: boolean @@ -43,8 +44,10 @@ export function useSpeech(onResult: (text: string) => void): SpeechState & Speec }) useSpeechRecognitionEvent("error", (event) => { - // "no-speech" is not really an error — user just didn't say anything - if (event.error === "no-speech") { + // "no-speech" and "aborted" aren't failures — see src/lib/speech-errors.ts. + // These events are module-global: another screen's cleanup abort() lands + // here too, and alerting on it blamed voice input the user never used. + if (isBenignSpeechError(event.error)) { setListening(false) return } @@ -77,10 +80,17 @@ export function useSpeech(onResult: (text: string) => void): SpeechState & Speec }, []) // Stop the native recognition session when the screen unmounts — otherwise - // the mic stays hot in the background. abort() is a no-op when not listening. + // the mic stays hot in the background. abort() is NOT a no-op when idle: + // Android emits a global "aborted" error event, which other mounted + // session screens receive (the back-from-subagent alert). Only abort + // when this hook actually started listening. + const listeningRef = useRef(false) + useEffect(() => { + listeningRef.current = listening + }, [listening]) useEffect(() => { return () => { - ExpoSpeechRecognitionModule.abort() + if (listeningRef.current) ExpoSpeechRecognitionModule.abort() } }, [])