fix: voice input in Electron - local Whisper STT + network error handling
- Add local Whisper STT via Transformers.js with Web Worker (no UI freeze) - Default sttProvider to 'local' in Electron (browser STT unavailable) - Fix infinite toast loop: stop auto-restart on network errors - Add retry limit with exponential backoff for transient STT errors - Append voice transcript to input field (append-inline), not replace - Add model catalog with download/load button in Voice Settings
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
|
||||
import { audioStreamService } from '@/lib/voice/audioStreamService';
|
||||
import { wasmSttService } from '@/lib/voice/wasmSttService';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { getSyncMessages, getSyncParts } from '@/sync/sync-refs';
|
||||
@@ -127,6 +128,9 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
const pendingFinalTranscriptRef = useRef('');
|
||||
const finalTranscriptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const deviceChangeRestartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const consecutiveRecoveryRetriesRef = useRef(0);
|
||||
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isFinalizingRef = useRef(false);
|
||||
|
||||
// Store access
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
@@ -157,13 +161,16 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
const sttProvider = useConfigStore((state) => state.sttProvider);
|
||||
const sttServerUrl = useConfigStore((state) => state.sttServerUrl);
|
||||
const sttModel = useConfigStore((state) => state.sttModel);
|
||||
const wasmSttModel = useConfigStore((state) => state.wasmSttModel);
|
||||
const sttLanguage = useConfigStore((state) => state.sttLanguage);
|
||||
const sttSilenceThresholdDb = useConfigStore((state) => state.sttSilenceThresholdDb);
|
||||
const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
|
||||
|
||||
const isSupported = sttProvider === 'server'
|
||||
? audioStreamService.isSupported()
|
||||
: browserVoiceService.isSupported();
|
||||
: sttProvider === 'wasm'
|
||||
? wasmSttService.isSupported()
|
||||
: browserVoiceService.isSupported();
|
||||
|
||||
// Server TTS for mobile (bypasses Safari audio restrictions)
|
||||
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({
|
||||
@@ -287,6 +294,20 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
const handleSpeechErrorRef = useRef<((errorMsg: string) => void) | null>(null);
|
||||
const handleSpeechResultRef = useRef<((text: string, isFinal: boolean) => Promise<void>) | null>(null);
|
||||
|
||||
// Start STT via the currently-selected provider.
|
||||
// Called by auto-recovery, restart-after-TTS, and visibility-resume paths.
|
||||
const startCurrentSTT = useCallback((lang: string) => {
|
||||
if (sttProvider === 'server') {
|
||||
void audioStreamService.startListening(lang, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else if (sttProvider === 'wasm') {
|
||||
void wasmSttService.startListening(lang, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else if (isMobile) {
|
||||
browserVoiceService.startListeningSync(lang, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else {
|
||||
browserVoiceService.startListening(lang, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
}
|
||||
}, [sttProvider, isMobile]);
|
||||
|
||||
// Handle speech recognition error
|
||||
const handleSpeechError = useCallback((errorMsg: string) => {
|
||||
// Ignore errors if we've already stopped voice mode
|
||||
@@ -316,26 +337,59 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Network / server-unreachable errors: don't retry at all.
|
||||
// The user must fix connectivity and then manually restart voice.
|
||||
const isNetworkError =
|
||||
normalizedError.includes('network') ||
|
||||
normalizedError.includes('connection') ||
|
||||
normalizedError.includes('check connection');
|
||||
|
||||
if (isNetworkError) {
|
||||
console.error('[useBrowserVoice] Network error — staying in error state:', errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus('error');
|
||||
consecutiveRecoveryRetriesRef.current = 0;
|
||||
if (recoveryTimerRef.current !== null) {
|
||||
clearTimeout(recoveryTimerRef.current);
|
||||
recoveryTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[useBrowserVoice] Recognition error:', errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus('error');
|
||||
|
||||
// Auto-recover from certain errors
|
||||
if (!errorMsg.includes('permission') && !errorMsg.includes('not allowed')) {
|
||||
setTimeout(() => {
|
||||
if (isActiveRef.current) {
|
||||
setStatus('listening');
|
||||
setError(null);
|
||||
if (sttProvider === 'server') {
|
||||
audioStreamService.startListening(language, handleSpeechResultRef.current!, handleSpeechError).catch(() => {});
|
||||
} else {
|
||||
browserVoiceService.startListening(language, handleSpeechResultRef.current!, handleSpeechError);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
// Auto-recover from transient / non-permission errors with limited retries.
|
||||
// Skip recovery when finalising — the user explicitly stopped voice.
|
||||
if (isPermissionStyleError || isFinalizingRef.current) {
|
||||
consecutiveRecoveryRetriesRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [language, conversationMode, sttProvider]);
|
||||
|
||||
const nextRetry = consecutiveRecoveryRetriesRef.current + 1;
|
||||
consecutiveRecoveryRetriesRef.current = nextRetry;
|
||||
const MAX_RECOVERY_RETRIES = 3;
|
||||
|
||||
if (nextRetry <= MAX_RECOVERY_RETRIES) {
|
||||
const delay = Math.min(1000 * Math.pow(2, nextRetry - 1), 8000);
|
||||
console.log(`[useBrowserVoice] Scheduling recovery retry ${nextRetry}/${MAX_RECOVERY_RETRIES} in ${delay}ms`);
|
||||
|
||||
if (recoveryTimerRef.current !== null) {
|
||||
clearTimeout(recoveryTimerRef.current);
|
||||
}
|
||||
recoveryTimerRef.current = setTimeout(() => {
|
||||
recoveryTimerRef.current = null;
|
||||
if (!isActiveRef.current) return;
|
||||
setStatus('listening');
|
||||
setError(null);
|
||||
startCurrentSTT(language);
|
||||
}, delay);
|
||||
} else {
|
||||
console.log('[useBrowserVoice] Max recovery retries reached — staying in error state');
|
||||
}
|
||||
}, [language, conversationMode, startCurrentSTT]);
|
||||
|
||||
// Update the ref when handleSpeechError changes
|
||||
useEffect(() => {
|
||||
@@ -362,7 +416,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
|
||||
// Non-continuous mode: fill chat input only, do not auto-send.
|
||||
if (!conversationMode) {
|
||||
setPendingInputText(finalText.trim(), 'replace');
|
||||
setPendingInputText(finalText.trim(), 'append-inline');
|
||||
processingMessageRef.current = false;
|
||||
isActiveRef.current = false;
|
||||
setStatus('idle');
|
||||
@@ -446,19 +500,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
}
|
||||
|
||||
setStatus('listening');
|
||||
if (sttProvider === 'server') {
|
||||
audioStreamService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!).catch((err) => {
|
||||
console.error('[useBrowserVoice] Failed to restart server STT:', err);
|
||||
});
|
||||
} else if (isMobile) {
|
||||
try {
|
||||
browserVoiceService.startListeningSync(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} catch (err) {
|
||||
console.error('[useBrowserVoice] Failed to restart listening:', err);
|
||||
}
|
||||
} else {
|
||||
browserVoiceService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
}
|
||||
startCurrentSTT(language);
|
||||
} else {
|
||||
// In non-continuous mode, return to idle after AI responds
|
||||
isActiveRef.current = false;
|
||||
@@ -547,19 +589,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
// Only restart listening if conversation mode is enabled
|
||||
if (conversationMode) {
|
||||
setStatus('listening');
|
||||
if (sttProvider === 'server') {
|
||||
audioStreamService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!).catch((err) => {
|
||||
console.error('[useBrowserVoice] Failed to restart server STT after speech error:', err);
|
||||
});
|
||||
} else if (isMobile) {
|
||||
try {
|
||||
browserVoiceService.startListeningSync(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} catch (restartErr) {
|
||||
console.error('[useBrowserVoice] Failed to restart listening after speech error:', restartErr);
|
||||
}
|
||||
} else {
|
||||
browserVoiceService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
}
|
||||
startCurrentSTT(language);
|
||||
} else {
|
||||
// In non-continuous mode, return to idle after error
|
||||
isActiveRef.current = false;
|
||||
@@ -583,7 +613,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
setStatus('error');
|
||||
processingMessageRef.current = false;
|
||||
}
|
||||
}, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isMobile, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, summarizeVoiceConversation, summarizeCharacterThreshold, conversationMode, sttProvider]);
|
||||
}, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, summarizeVoiceConversation, summarizeCharacterThreshold, conversationMode, startCurrentSTT]);
|
||||
|
||||
// Handle speech recognition result
|
||||
const handleSpeechResult = useCallback(async (text: string, isFinal: boolean) => {
|
||||
@@ -591,6 +621,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
const normalized = text.trim();
|
||||
if (!isFinal || !normalized) return;
|
||||
|
||||
console.log('[useBrowserVoice] Speech result:', normalized);
|
||||
pendingFinalTranscriptRef.current = normalized;
|
||||
|
||||
if (finalTranscriptTimerRef.current) {
|
||||
@@ -626,13 +657,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
pendingResumeOnVisibleRef.current = false;
|
||||
setStatus('listening');
|
||||
try {
|
||||
if (sttProvider === 'server') {
|
||||
void audioStreamService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else if (isMobile) {
|
||||
browserVoiceService.startListeningSync(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else {
|
||||
browserVoiceService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
}
|
||||
startCurrentSTT(language);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to resume voice';
|
||||
setError(errorMsg);
|
||||
@@ -644,7 +669,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [conversationMode, isMobile, language, sttProvider]);
|
||||
}, [conversationMode, isMobile, language, sttProvider, startCurrentSTT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
@@ -679,17 +704,10 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
if (sttProvider === 'server') {
|
||||
audioStreamService.stopListening();
|
||||
void audioStreamService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else {
|
||||
browserVoiceService.stopListening();
|
||||
if (isMobile) {
|
||||
browserVoiceService.startListeningSync(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
} else {
|
||||
void browserVoiceService.startListening(language, handleSpeechResultRef.current!, handleSpeechErrorRef.current!);
|
||||
}
|
||||
}
|
||||
browserVoiceService.stopListening();
|
||||
audioStreamService.stopListening();
|
||||
wasmSttService.stopListening();
|
||||
startCurrentSTT(language);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Microphone source changed. Tap mic to continue.';
|
||||
setError(errorMsg);
|
||||
@@ -708,7 +726,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
deviceChangeRestartTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMobile, language, status, sttProvider]);
|
||||
}, [isMobile, language, status, sttProvider, startCurrentSTT]);
|
||||
|
||||
// Update the ref when handleSpeechResult changes
|
||||
useEffect(() => {
|
||||
@@ -720,8 +738,8 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
if (!isSupported) {
|
||||
return false;
|
||||
}
|
||||
if (sttProvider === 'server') {
|
||||
// getUserMedia permission is requested on startListening; nothing to prepare
|
||||
if (sttProvider === 'server' || sttProvider === 'wasm') {
|
||||
// Permission is requested on startListening; nothing to pre-prepare
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -750,6 +768,12 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
|
||||
isActiveRef.current = true;
|
||||
lastTranscriptRef.current = '';
|
||||
consecutiveRecoveryRetriesRef.current = 0;
|
||||
isFinalizingRef.current = false;
|
||||
if (recoveryTimerRef.current !== null) {
|
||||
clearTimeout(recoveryTimerRef.current);
|
||||
recoveryTimerRef.current = null;
|
||||
}
|
||||
setError(null);
|
||||
setStatus('listening');
|
||||
|
||||
@@ -774,6 +798,40 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sttProvider === 'wasm') {
|
||||
// WASM STT: ensure model is loaded then start recording
|
||||
const modelStatus = wasmSttService.getModelStatus();
|
||||
console.log('[useBrowserVoice] WASM model status:', modelStatus.state);
|
||||
if (modelStatus.state !== 'ready') {
|
||||
try {
|
||||
setStatus('processing');
|
||||
await wasmSttService.loadModel(wasmSttModel);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load Whisper model';
|
||||
console.error('[useBrowserVoice] WASM model load error:', errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus('error');
|
||||
isActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
wasmSttService.configure({
|
||||
silenceThresholdDb: sttSilenceThresholdDb,
|
||||
silenceHoldMs: sttSilenceHoldMs,
|
||||
});
|
||||
try {
|
||||
await wasmSttService.startListening(language, handleSpeechResult, handleSpeechError);
|
||||
console.log('[useBrowserVoice] WASM listening started');
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to start voice';
|
||||
console.error('[useBrowserVoice] WASM STT start error:', errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus('error');
|
||||
isActiveRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser STT
|
||||
// On mobile, use sync path to ensure SpeechRecognition.start() is called
|
||||
// within the same user gesture context (required by iOS Safari)
|
||||
@@ -812,17 +870,23 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
isActiveRef.current = false;
|
||||
}
|
||||
}
|
||||
}, [isSupported, currentSessionId, language, handleSpeechResult, handleSpeechError, isMobile, unlockServerTTSAudio, unlockSayTTSAudio, sttProvider, sttServerUrl, sttModel, sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs]);
|
||||
}, [isSupported, currentSessionId, language, handleSpeechResult, handleSpeechError, isMobile, unlockServerTTSAudio, unlockSayTTSAudio, sttProvider, sttServerUrl, sttModel, wasmSttModel, sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs]);
|
||||
|
||||
// Stop voice mode
|
||||
const stopVoice = useCallback(() => {
|
||||
isActiveRef.current = false;
|
||||
processingMessageRef.current = false;
|
||||
pendingResumeOnVisibleRef.current = false;
|
||||
consecutiveRecoveryRetriesRef.current = 0;
|
||||
isFinalizingRef.current = false;
|
||||
if (deviceChangeRestartTimerRef.current) {
|
||||
clearTimeout(deviceChangeRestartTimerRef.current);
|
||||
deviceChangeRestartTimerRef.current = null;
|
||||
}
|
||||
if (recoveryTimerRef.current !== null) {
|
||||
clearTimeout(recoveryTimerRef.current);
|
||||
recoveryTimerRef.current = null;
|
||||
}
|
||||
pendingFinalTranscriptRef.current = '';
|
||||
if (finalTranscriptTimerRef.current) {
|
||||
clearTimeout(finalTranscriptTimerRef.current);
|
||||
@@ -830,6 +894,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
}
|
||||
browserVoiceService.stopListening();
|
||||
audioStreamService.stopListening();
|
||||
wasmSttService.stopListening();
|
||||
browserVoiceService.cancelSpeech();
|
||||
stopServerTTS(); // Also stop server TTS if playing
|
||||
stopSayTTS(); // Also stop Say TTS if playing
|
||||
@@ -842,6 +907,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
isFinalizingRef.current = true;
|
||||
pendingResumeOnVisibleRef.current = false;
|
||||
if (deviceChangeRestartTimerRef.current) {
|
||||
clearTimeout(deviceChangeRestartTimerRef.current);
|
||||
@@ -869,6 +935,28 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sttProvider === 'wasm') {
|
||||
// Inference runs in a Web Worker — no main-thread freeze.
|
||||
void wasmSttService.finishListening().then(() => {
|
||||
window.setTimeout(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (pendingFinalTranscriptRef.current || finalTranscriptTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (processingMessageRef.current) {
|
||||
return;
|
||||
}
|
||||
isActiveRef.current = false;
|
||||
processingMessageRef.current = false;
|
||||
setStatus('idle');
|
||||
isFinalizingRef.current = false;
|
||||
}, FINAL_TRANSCRIPT_SETTLE_MS + 200);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
browserVoiceService.stopListening();
|
||||
window.setTimeout(() => {
|
||||
if (!isActiveRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user