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
14 changes: 14 additions & 0 deletions src/lib/speech-errors.ts
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 14 additions & 4 deletions src/lib/speech.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
}, [])

Expand Down
Loading