From c14403aabe29540bfe962ea7ae9f574824da4a90 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 1 May 2026 23:38:07 +0000 Subject: [PATCH 1/2] fix: add startup timeout to STT microphone start Race microphone startup against a 10s timeout to prevent the voice button from getting stuck in a loading state when the microphone never actually starts. On timeout, abort the recorder/recognizer, reset all STT state, and return false so the UI can recover. --- frontend/src/hooks/useSTT.ts | 74 ++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index f6ddac00..fdf5e4c7 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -5,6 +5,8 @@ import { AudioRecorder } from '@/lib/audioRecorder' import { sttApi } from '@/api/stt' import { DEFAULT_STT_CONFIG } from '@/api/types/settings' +const STT_START_TIMEOUT_MS = 10_000 + export function useSTT(userId = 'default') { const { preferences } = useSettings(userId) const [isRecording, setIsRecording] = useState(false) @@ -21,6 +23,8 @@ export function useSTT(userId = 'default') { const userIdRef = useRef(userId) const errorTimeoutRef = useRef | null>(null) const lastProcessedBlobRef = useRef(null) + const startupTimeoutRef = useRef | null>(null) + const startOpIdRef = useRef(0) useEffect(() => { userIdRef.current = userId @@ -202,6 +206,9 @@ export function useSTT(userId = 'default') { setError(null) lastProcessedBlobRef.current = null + const startOpId = ++startOpIdRef.current + clearStartupTimeout() + if (isExternalProvider) { if (!audioRecorder.current) { audioRecorder.current = new AudioRecorder() @@ -210,11 +217,30 @@ export function useSTT(userId = 'default') { try { setIsProcessing(true) - await audioRecorder.current.start() + + const startupPromise = audioRecorder.current.start() + const timeoutPromise = new Promise((_, reject) => { + startupTimeoutRef.current = setTimeout(() => { + if (startOpIdRef.current !== startOpId) return + reject(new Error('Microphone start timed out')) + }, STT_START_TIMEOUT_MS) + }) + + await Promise.race([startupPromise, timeoutPromise]) + clearStartupTimeout() + + if (startOpIdRef.current !== startOpId) return false + setIsProcessing(false) return true } catch (err) { + clearStartupTimeout() + if (startOpIdRef.current !== startOpId) return false setIsProcessing(false) + if (err instanceof Error && err.message === 'Microphone start timed out') { + abortAndResetOnTimeout() + return false + } setIsError(true) setError(err instanceof Error ? err.message : 'Failed to start recording') return false @@ -228,16 +254,35 @@ export function useSTT(userId = 'default') { try { setIsProcessing(true) - await recognizer.current.start(options) + + const startupPromise = recognizer.current.start(options) + const timeoutPromise = new Promise((_, reject) => { + startupTimeoutRef.current = setTimeout(() => { + if (startOpIdRef.current !== startOpId) return + reject(new Error('Microphone start timed out')) + }, STT_START_TIMEOUT_MS) + }) + + await Promise.race([startupPromise, timeoutPromise]) + clearStartupTimeout() + + if (startOpIdRef.current !== startOpId) return false + return true } catch (err) { + clearStartupTimeout() + if (startOpIdRef.current !== startOpId) return false setIsProcessing(false) + if (err instanceof Error && err.message === 'Microphone start timed out') { + abortAndResetOnTimeout() + return false + } setIsError(true) setError(err instanceof Error ? err.message : 'Failed to start recording') return false } } - }, [isSupported, isEnabled, isExternalProvider, config.language, setupAudioRecorder]) + }, [isSupported, isEnabled, isExternalProvider, config.language, setupAudioRecorder, clearStartupTimeout, abortAndResetOnTimeout]) const stopRecording = useCallback(() => { if (isExternalProvider && audioRecorder.current) { @@ -283,11 +328,32 @@ export function useSTT(userId = 'default') { setInterimTranscript('') }, []) + const clearStartupTimeout = useCallback(() => { + if (startupTimeoutRef.current) { + clearTimeout(startupTimeoutRef.current) + startupTimeoutRef.current = null + } + }, []) + + const abortAndResetOnTimeout = useCallback(() => { + if (isExternalProvider && audioRecorder.current) { + audioRecorder.current.abort() + } else { + recognizer.current.abort() + } + setIsRecording(false) + setIsProcessing(false) + setState('idle') + setIsError(true) + setError('Microphone start timed out') + }, [isExternalProvider]) + useEffect(() => { return () => { if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + clearStartupTimeout() } - }, []) + }, [clearStartupTimeout]) return { isRecording, From f8168ade1be7eed48653ce2d474b534f1703d186 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 1 May 2026 20:13:20 -0400 Subject: [PATCH 2/2] refactor(frontend): improve voice status overlay and scroll button visibility --- .../src/components/message/PromptInput.tsx | 4 +- .../components/message/VoiceStatusOverlay.tsx | 58 ++++++++++--------- frontend/src/hooks/useSTT.ts | 40 ++++++------- 3 files changed, 52 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index b5f63a18..624d3c33 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1241,7 +1241,7 @@ return (
- {isMobile && showScrollButton ? ( + {isMobile && showScrollButton && !showVoiceFeedback ? (
) diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index fdf5e4c7..f4dc5e5c 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -187,6 +187,26 @@ export function useSTT(userId = 'default') { } }, [isEnabled, isExternalProvider, setupAudioRecorder]) + const clearStartupTimeout = useCallback(() => { + if (startupTimeoutRef.current) { + clearTimeout(startupTimeoutRef.current) + startupTimeoutRef.current = null + } + }, []) + + const abortAndResetOnTimeout = useCallback(() => { + if (isExternalProvider && audioRecorder.current) { + audioRecorder.current.abort() + } else { + recognizer.current.abort() + } + setIsRecording(false) + setIsProcessing(false) + setState('idle') + setIsError(true) + setError('Microphone start timed out') + }, [isExternalProvider]) + const startRecording = useCallback(async (): Promise => { if (!isSupported) { setError('Speech recognition is not supported in this browser') @@ -328,26 +348,6 @@ export function useSTT(userId = 'default') { setInterimTranscript('') }, []) - const clearStartupTimeout = useCallback(() => { - if (startupTimeoutRef.current) { - clearTimeout(startupTimeoutRef.current) - startupTimeoutRef.current = null - } - }, []) - - const abortAndResetOnTimeout = useCallback(() => { - if (isExternalProvider && audioRecorder.current) { - audioRecorder.current.abort() - } else { - recognizer.current.abort() - } - setIsRecording(false) - setIsProcessing(false) - setState('idle') - setIsError(true) - setError('Microphone start timed out') - }, [isExternalProvider]) - useEffect(() => { return () => { if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)