Files
openchamber/packages/ui/src/hooks/useBrowserVoice.ts
T
yangyaofeiandBohdan Triapitsyn 06526767a2 feat(tts/stt): add API key support for OpenAI-compatible custom providers (#1361)
* feat(tts/stt): add API key support for OpenAI-compatible custom providers

## Problem
Custom (OpenAI-compatible) TTS/STT provider in Voice Settings has no way to
pass an API key or bearer token. Many self-hosted or third-party compatible
servers require authentication, making them unreachable from OpenChamber.

The server-side TTS route already accepts an `apiKey` parameter, but the
frontend never sends it. The STT route hardcodes `'not-required'`.

## Implementation
- Add `openaiCompatibleApiKey` to Zustand config store, persisted to localStorage
- Add API Key input field in VoiceSettings.tsx under the custom provider section
- Wire `openaiCompatibleApiKey` through useServerTTS to the TTS backend
- Add `apiKey` field to AudioStreamConfig for STT, forwarded as X-API-Key header
- Update server STT route to accept and forward X-API-Key to transcribeAudio
- Update stt.js to use client-provided apiKey before falling back to env var

## Files changed
- packages/ui/src/stores/useConfigStore.ts
- packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
- packages/ui/src/hooks/useServerTTS.ts
- packages/ui/src/hooks/useBrowserVoice.ts
- packages/ui/src/lib/voice/audioStreamService.ts
- packages/web/server/lib/tts/routes.js
- packages/web/server/lib/tts/stt.js

* feat(tts/stt): add separate API key support for custom TTS and STT providers

## Problem
Custom (OpenAI-compatible) TTS and STT providers in Voice Settings have no way
to pass API keys. Many self-hosted or third-party compatible servers require
authentication, making them unreachable from OpenChamber Desktop (Electron).

## Implementation
- Add `openaiCompatibleApiKey` for TTS (persisted to localStorage, passed in JSON body)
- Add `sttApiKey` for STT (persisted to localStorage, passed via Authorization: Bearer header)
- Two independent keys: TTS and STT are configured separately
- STT authentication follows OpenAI standard (Authorization: Bearer <token>)
- TTS authentication follows existing pattern (apiKey in JSON body)
- Backend STT route extracts bearer token from Authorization header
- Backend STT service prefers client-provided key over OPENAI_API_KEY env var

## Fixes
- Fixed P1: ConfigStore interface now declares setOpenaiCompatibleApiKey setter
- STT API key is only forwarded when sttProvider === 'server' (not leaked to other providers)

## Files changed (7)
- packages/ui/src/stores/useConfigStore.ts
- packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
- packages/ui/src/hooks/useServerTTS.ts
- packages/ui/src/hooks/useBrowserVoice.ts
- packages/ui/src/lib/voice/audioStreamService.ts
- packages/web/server/lib/tts/routes.js
- packages/web/server/lib/tts/stt.js

* fix: refresh server STT callback when API key changes

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-24 00:58:22 +03:00

1003 lines
38 KiB
TypeScript

/**
* useBrowserVoice Hook
*
* React hook for browser-based voice chat integration.
* Manages speech recognition, AI message sending, and speech synthesis.
*
* @example
* ```typescript
* const {
* status,
* isSupported,
* language,
* setLanguage,
* startVoice,
* stopVoice,
* prepareVoice,
* isMobile,
* } = useBrowserVoice();
*
* // Start voice mode
* startVoice();
*
* // Change language
* setLanguage('es-ES');
* ```
*/
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';
import { useConfigStore } from '@/stores/useConfigStore';
import { useServerTTS } from './useServerTTS';
import { useSayTTS } from './useSayTTS';
import { sanitizeForTTS } from '@/lib/voice/summarize';
export type BrowserVoiceStatus = 'idle' | 'listening' | 'processing' | 'speaking' | 'error';
export interface UseBrowserVoiceReturn {
/** Current voice status */
status: BrowserVoiceStatus;
/** Whether browser voice is supported */
isSupported: boolean;
/** Error message if any */
error: string | null;
/** Current language for recognition/synthesis */
language: string;
/** Set language for voice operations */
setLanguage: (lang: string) => void;
/** Start voice mode (listening) */
startVoice: () => void;
/** Stop voice mode */
stopVoice: () => void;
/** Finish current voice input and process it */
finishVoiceInput: () => void;
/** Whether conversation mode is active */
conversationMode: boolean;
/** Toggle conversation mode */
toggleConversationMode: () => void;
/** Prepare voice for mobile (request permission) */
prepareVoice: () => Promise<boolean>;
/** Whether the device is mobile */
isMobile: boolean;
/** Current voice provider */
voiceProvider: 'browser' | 'openai' | 'openai-compatible' | 'say';
}
// Storage key for persisting language preference
const LANGUAGE_STORAGE_KEY = 'browserVoiceLanguage';
// Storage key for persisting conversation mode preference
const CONVERSATION_MODE_STORAGE_KEY = 'browserVoiceConversationMode';
const LANGUAGE_CHANGE_EVENT = 'openchamber:voice-language-changed';
const CONVERSATION_MODE_CHANGE_EVENT = 'openchamber:voice-conversation-mode-changed';
const FINAL_TRANSCRIPT_SETTLE_MS = 1200;
const DEVICE_CHANGE_RESTART_DELAY_MS = 700;
const BLOCKED_SPEECH_LANGUAGES = new Set(['ru', 'ru-RU']);
const sanitizeSpeechLanguage = (lang: string): string => {
const normalized = (lang || '').trim();
if (!normalized) {
return 'en-US';
}
const base = normalized.split('-')[0].toLowerCase();
if (BLOCKED_SPEECH_LANGUAGES.has(normalized) || BLOCKED_SPEECH_LANGUAGES.has(base)) {
return 'en-US';
}
return normalized;
};
/**
* Hook for managing browser-based voice conversations
*/
export function useBrowserVoice(): UseBrowserVoiceReturn {
const [status, setStatus] = useState<BrowserVoiceStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [language, setLanguageState] = useState<string>(() => {
// Try to load from localStorage, fallback to navigator.language
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (saved) return sanitizeSpeechLanguage(saved);
}
return sanitizeSpeechLanguage(navigator.language || 'en-US');
});
const [conversationMode, setConversationModeState] = useState<boolean>(() => {
// Try to load from localStorage, default to false
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(CONVERSATION_MODE_STORAGE_KEY);
return saved === 'true';
}
return false;
});
// Mobile detection
const isMobile = useMemo(() => {
if (typeof navigator === 'undefined') return false;
const userAgent = navigator.userAgent.toLowerCase();
return /iphone|ipad|ipod|android|mobile|webos|blackberry|iemobile|opera mini/i.test(userAgent);
}, []);
// Refs for managing async operations
const isActiveRef = useRef(false);
const processingMessageRef = useRef(false);
const lastTranscriptRef = useRef('');
const pendingResumeOnVisibleRef = useRef(false);
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);
const sendMessage = useSessionUIStore((s) => s.sendMessage);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const createSession = useSessionUIStore((s) => s.createSession);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
const voiceProvider = useConfigStore((state) => state.voiceProvider);
const speechRate = useConfigStore((state) => state.speechRate);
const speechPitch = useConfigStore((state) => state.speechPitch);
const speechVolume = useConfigStore((state) => state.speechVolume);
const sayVoice = useConfigStore((state) => state.sayVoice);
const browserVoice = useConfigStore((state) => state.browserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel);
const sttApiKey = useConfigStore((state) => state.sttApiKey);
const shouldCheckOpenAIAvailability = voiceModeEnabled && (voiceProvider === 'openai' || voiceProvider === 'openai-compatible');
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
// STT provider config
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()
: sttProvider === 'wasm'
? wasmSttService.isSupported()
: browserVoiceService.isSupported();
// Server TTS for mobile (bypasses Safari audio restrictions)
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({
enabled: shouldCheckOpenAIAvailability,
availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai',
});
// macOS Say TTS
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable, unlockAudio: unlockSayTTSAudio } = useSayTTS({
enabled: shouldCheckSayAvailability,
});
// Stop voice when session changes to prevent microphone from staying active
// This ensures voice mode doesn't carry over between sessions
const prevSessionIdRef = useRef<string | null>(null);
useEffect(() => {
if (prevSessionIdRef.current !== null && prevSessionIdRef.current !== currentSessionId) {
// Session changed - stop any active voice session
if (isActiveRef.current) {
console.log('[useBrowserVoice] Session changed, stopping voice');
isActiveRef.current = false;
processingMessageRef.current = false;
browserVoiceService.stopListening();
audioStreamService.stopListening();
browserVoiceService.cancelSpeech();
setStatus('idle');
setError(null);
}
}
prevSessionIdRef.current = currentSessionId;
}, [currentSessionId]);
// Persist language preference
const setLanguage = useCallback((lang: string) => {
const nextLang = sanitizeSpeechLanguage(lang);
setLanguageState(nextLang);
if (typeof window !== 'undefined') {
localStorage.setItem(LANGUAGE_STORAGE_KEY, nextLang);
window.dispatchEvent(new CustomEvent<string>(LANGUAGE_CHANGE_EVENT, { detail: nextLang }));
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleLanguageEvent = (event: Event) => {
const customEvent = event as CustomEvent<string>;
const nextLang = sanitizeSpeechLanguage(customEvent.detail || localStorage.getItem(LANGUAGE_STORAGE_KEY) || 'en-US');
setLanguageState((prev) => (prev === nextLang ? prev : nextLang));
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== LANGUAGE_STORAGE_KEY || !event.newValue) {
return;
}
const nextLang = sanitizeSpeechLanguage(event.newValue);
setLanguageState((prev) => (prev === nextLang ? prev : nextLang));
};
window.addEventListener(LANGUAGE_CHANGE_EVENT, handleLanguageEvent as EventListener);
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener(LANGUAGE_CHANGE_EVENT, handleLanguageEvent as EventListener);
window.removeEventListener('storage', handleStorage);
};
}, []);
// Toggle conversation mode
const toggleConversationMode = useCallback(() => {
setConversationModeState((prev) => {
const next = !prev;
browserVoiceService.setConversationMode(next);
if (typeof window !== 'undefined') {
localStorage.setItem(CONVERSATION_MODE_STORAGE_KEY, String(next));
window.dispatchEvent(new CustomEvent<boolean>(CONVERSATION_MODE_CHANGE_EVENT, { detail: next }));
}
return next;
});
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleConversationModeEvent = (event: Event) => {
const customEvent = event as CustomEvent<boolean>;
const detail = customEvent.detail;
if (typeof detail !== 'boolean') {
return;
}
setConversationModeState((prev) => (prev === detail ? prev : detail));
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== CONVERSATION_MODE_STORAGE_KEY || event.newValue == null) {
return;
}
const next = event.newValue === 'true';
setConversationModeState((prev) => (prev === next ? prev : next));
};
window.addEventListener(CONVERSATION_MODE_CHANGE_EVENT, handleConversationModeEvent as EventListener);
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener(CONVERSATION_MODE_CHANGE_EVENT, handleConversationModeEvent as EventListener);
window.removeEventListener('storage', handleStorage);
};
}, []);
// Initialize conversation mode in service on mount
useEffect(() => {
browserVoiceService.setConversationMode(conversationMode);
}, [conversationMode]);
// Refs for callbacks to avoid circular dependencies
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
if (!isActiveRef.current) {
console.log('[useBrowserVoice] Ignoring error after voice stopped:', errorMsg);
return;
}
const normalizedError = errorMsg.toLowerCase();
if (normalizedError.includes('aborted')) {
console.log('[useBrowserVoice] Ignoring non-fatal aborted error');
setError(null);
setStatus('listening');
return;
}
const isHidden = typeof document !== 'undefined' && document.visibilityState !== 'visible';
const isPermissionStyleError =
normalizedError.includes('permission') ||
normalizedError.includes('not allowed') ||
normalizedError.includes('service not allowed');
if (isHidden && isPermissionStyleError && conversationMode) {
console.log('[useBrowserVoice] Suppressing permission error while app hidden; will resume on visibility');
pendingResumeOnVisibleRef.current = true;
setError(null);
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 transient / non-permission errors with limited retries.
// Skip recovery when finalising — the user explicitly stopped voice.
if (isPermissionStyleError || isFinalizingRef.current) {
consecutiveRecoveryRetriesRef.current = 0;
return;
}
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(() => {
handleSpeechErrorRef.current = handleSpeechError;
}, [handleSpeechError]);
const processFinalTranscript = useCallback(async (finalText: string) => {
if (!finalText.trim() || !isActiveRef.current) return;
// Prevent duplicate processing of same transcript
if (finalText.trim() === lastTranscriptRef.current) return;
lastTranscriptRef.current = finalText.trim();
// Check if provider and model are configured
if (!currentProviderId || !currentModelId) {
setError('No provider or model configured. Please configure them in settings.');
setStatus('error');
return;
}
// Stop listening while processing
browserVoiceService.stopListening();
audioStreamService.stopListening();
// Non-continuous mode: fill chat input only, do not auto-send.
if (!conversationMode) {
setPendingInputText(finalText.trim(), 'append-inline');
processingMessageRef.current = false;
isActiveRef.current = false;
setStatus('idle');
return;
}
setStatus('processing');
processingMessageRef.current = true;
try {
// Create session if none exists
let sessionId = currentSessionId;
if (!sessionId) {
console.log('[useBrowserVoice] No active session, creating new session...');
const newSession = await createSession();
if (!newSession) {
setError('Failed to create session');
setStatus('error');
processingMessageRef.current = false;
return;
}
sessionId = newSession.id;
console.log('[useBrowserVoice] Created new session:', sessionId);
}
// Send message to AI
await sendMessage(
finalText.trim(),
currentProviderId,
currentModelId,
currentAgentName ?? undefined
);
// Wait for AI response and speak it
// We'll poll for new assistant messages
const checkForResponse = async () => {
if (!isActiveRef.current || !sessionId) return;
const rawMessages = getSyncMessages(sessionId);
const assistantMessages = rawMessages
.filter(m => m.role === 'assistant')
.sort((a, b) => {
const aTime = (a as { time?: { created?: number } }).time?.created ?? 0;
const bTime = (b as { time?: { created?: number } }).time?.created ?? 0;
return bTime - aTime;
});
if (assistantMessages.length > 0) {
const latestMessage = assistantMessages[0];
const parts = getSyncParts(latestMessage.id);
const textParts = parts
.filter((p: { type: string; text?: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text ?? '')
.join(' ');
if (textParts.trim()) {
// Speak the response
setStatus('speaking');
try {
const textToSpeak = sanitizeForTTS(textParts);
// Helper to restart listening after speech ends
// Only auto-restart if conversation mode is enabled
const restartListening = () => {
if (isActiveRef.current && conversationMode) {
const isHidden = typeof document !== 'undefined' && document.visibilityState !== 'visible';
if (isHidden) {
pendingResumeOnVisibleRef.current = true;
setStatus('idle');
return;
}
setStatus('listening');
startCurrentSTT(language);
} else {
// In non-continuous mode, return to idle after AI responds
isActiveRef.current = false;
setStatus('idle');
}
};
// Use server TTS when OpenAI (or OpenAI-compatible) provider is selected and available
if ((voiceProvider === 'openai' || voiceProvider === 'openai-compatible') && isServerTTSAvailable) {
const ttsVoice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice;
const ttsBaseURL = voiceProvider === 'openai-compatible' ? openaiCompatibleUrl : undefined;
const ttsModel = voiceProvider === 'openai-compatible' ? openaiCompatibleTtsModel : undefined;
console.log('[useBrowserVoice] Using server TTS with voice:', ttsVoice, 'provider:', voiceProvider);
await speakServerTTS(textToSpeak, {
voice: ttsVoice,
model: ttsModel,
speed: speechRate,
pitch: speechPitch,
volume: speechVolume,
baseURL: ttsBaseURL,
onStart: () => console.log('[useBrowserVoice] Server TTS started'),
onEnd: () => {
console.log('[useBrowserVoice] Server TTS ended');
restartListening();
},
onError: (errorMsg) => {
console.error('[useBrowserVoice] Server TTS error:', errorMsg);
setError(`Voice TTS failed: ${errorMsg}. Please check your settings or switch to Browser voice.`);
setStatus('error');
restartListening();
}
});
} else if (voiceProvider === 'say' && isSayTTSAvailable) {
// Use macOS 'say' command
console.log('[useBrowserVoice] Using macOS Say TTS with voice:', sayVoice);
// Convert speechRate (0.5-2.0) to words per minute (100-400)
const wordsPerMinute = Math.round(100 + (speechRate - 0.5) * 200);
await speakSayTTS(textToSpeak, {
voice: sayVoice,
rate: wordsPerMinute,
onStart: () => console.log('[useBrowserVoice] Say TTS started'),
onEnd: () => {
console.log('[useBrowserVoice] Say TTS ended');
restartListening();
},
onError: (errorMsg) => {
console.error('[useBrowserVoice] Say TTS error:', errorMsg);
restartListening();
}
});
} else {
// Use browser TTS (desktop and mobile)
// Pre-load voices and unlock audio context before speaking
console.log('[useBrowserVoice] Using browser TTS');
// Warn user if they selected OpenAI but it's unavailable
if (voiceProvider === 'openai' && !isServerTTSAvailable) {
console.warn('[useBrowserVoice] OpenAI voice selected but unavailable, falling back to browser voice');
setError('OpenAI voice unavailable (API key not configured). Using browser voice instead.');
}
await browserVoiceService.waitForVoices();
await browserVoiceService.resumeAudioContext();
await browserVoiceService.speakText(textToSpeak, language, () => {
// When speech ends, go back to listening if still active
restartListening();
}, { rate: speechRate, pitch: speechPitch, volume: speechVolume, voiceName: browserVoice || undefined });
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Speech failed';
// Ignore errors if we've stopped voice (e.g., user cancelled during speech)
if (!isActiveRef.current) {
console.log('[useBrowserVoice] Ignoring speech error after voice stopped:', errorMsg);
return;
}
console.error('[useBrowserVoice] Speech error:', errorMsg);
// Check for autoplay policy error
if (errorMsg.includes('not-allowed') || errorMsg.includes('autoplay')) {
setError('Audio blocked by browser. Please click the voice button again to enable audio.');
}
// Only restart listening if conversation mode is enabled
if (conversationMode) {
setStatus('listening');
startCurrentSTT(language);
} else {
// In non-continuous mode, return to idle after error
isActiveRef.current = false;
setStatus('idle');
}
}
return;
}
}
// Check again in 500ms
setTimeout(checkForResponse, 500);
};
// Start checking for response after a short delay
setTimeout(checkForResponse, 1000);
} catch (err) {
console.error('[useBrowserVoice] Send message error:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
setStatus('error');
processingMessageRef.current = false;
}
}, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, conversationMode, startCurrentSTT]);
// Handle speech recognition result
const handleSpeechResult = useCallback(async (text: string, isFinal: boolean) => {
if (!isActiveRef.current) return;
const normalized = text.trim();
if (!isFinal || !normalized) return;
console.log('[useBrowserVoice] Speech result:', normalized);
pendingFinalTranscriptRef.current = normalized;
if (finalTranscriptTimerRef.current) {
clearTimeout(finalTranscriptTimerRef.current);
}
finalTranscriptTimerRef.current = setTimeout(() => {
finalTranscriptTimerRef.current = null;
const transcript = pendingFinalTranscriptRef.current.trim();
pendingFinalTranscriptRef.current = '';
if (!transcript) return;
void processFinalTranscript(transcript);
}, FINAL_TRANSCRIPT_SETTLE_MS);
}, [processFinalTranscript]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
return;
}
if (!pendingResumeOnVisibleRef.current) {
return;
}
if (!isActiveRef.current || !conversationMode) {
pendingResumeOnVisibleRef.current = false;
return;
}
pendingResumeOnVisibleRef.current = false;
setStatus('listening');
try {
startCurrentSTT(language);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to resume voice';
setError(errorMsg);
setStatus('error');
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [conversationMode, isMobile, language, sttProvider, startCurrentSTT]);
useEffect(() => {
if (typeof navigator === 'undefined') {
return;
}
const mediaDevices = navigator.mediaDevices;
if (!mediaDevices || typeof mediaDevices.addEventListener !== 'function') {
return;
}
const handleDeviceChange = () => {
if (!isActiveRef.current || status !== 'listening') {
return;
}
if (deviceChangeRestartTimerRef.current) {
clearTimeout(deviceChangeRestartTimerRef.current);
}
deviceChangeRestartTimerRef.current = setTimeout(() => {
deviceChangeRestartTimerRef.current = null;
if (!isActiveRef.current || status !== 'listening') {
return;
}
const isHidden = typeof document !== 'undefined' && document.visibilityState !== 'visible';
if (isHidden) {
pendingResumeOnVisibleRef.current = true;
setStatus('idle');
return;
}
try {
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);
setStatus('error');
isActiveRef.current = false;
}
}, DEVICE_CHANGE_RESTART_DELAY_MS);
};
mediaDevices.addEventListener('devicechange', handleDeviceChange);
return () => {
mediaDevices.removeEventListener('devicechange', handleDeviceChange);
if (deviceChangeRestartTimerRef.current) {
clearTimeout(deviceChangeRestartTimerRef.current);
deviceChangeRestartTimerRef.current = null;
}
};
}, [isMobile, language, status, sttProvider, startCurrentSTT]);
// Update the ref when handleSpeechResult changes
useEffect(() => {
handleSpeechResultRef.current = handleSpeechResult;
}, [handleSpeechResult]);
// Prepare voice for mobile (request permission)
const prepareVoice = useCallback(async (): Promise<boolean> => {
if (!isSupported) {
return false;
}
if (sttProvider === 'server' || sttProvider === 'wasm') {
// Permission is requested on startListening; nothing to pre-prepare
return true;
}
try {
await browserVoiceService.prepareListening();
return true;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Microphone permission denied';
setError(errorMsg);
return false;
}
}, [isSupported, sttProvider]);
// Start voice mode
const startVoice = useCallback(async () => {
if (!isSupported) {
setError('Voice input not supported in this browser');
setStatus('error');
return;
}
if (!currentSessionId) {
setError('No active session');
setStatus('error');
return;
}
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');
if (sttProvider === 'server') {
// Server STT: configure the service then start async recording
audioStreamService.configure({
baseURL: sttServerUrl,
model: sttModel,
language: sttLanguage || undefined,
silenceThresholdDb: sttSilenceThresholdDb,
silenceHoldMs: sttSilenceHoldMs,
apiKey: sttApiKey || undefined,
});
try {
await audioStreamService.startListening(language, handleSpeechResult, handleSpeechError);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to start voice';
console.error('[useBrowserVoice] Server STT start error:', errorMsg);
setError(errorMsg);
setStatus('error');
isActiveRef.current = false;
}
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)
// Also unlock audio immediately for TTS playback later
if (isMobile) {
try {
// Unlock audio context synchronously within user gesture
browserVoiceService.unlockAudio().catch(() => {
// Audio unlock failed, but continue anyway
});
// Also unlock server TTS audio for mobile Safari (OpenAI)
unlockServerTTSAudio().catch(() => {
// Server TTS unlock failed, but continue anyway
});
// Also unlock Say TTS audio for mobile Safari (macOS Say)
unlockSayTTSAudio().catch(() => {
// Say TTS unlock failed, but continue anyway
});
browserVoiceService.startListeningSync(language, handleSpeechResult, handleSpeechError);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to start voice';
console.error('[useBrowserVoice] Mobile voice start error:', errorMsg);
setError(errorMsg);
setStatus('error');
isActiveRef.current = false;
}
} else {
// Desktop can use async path with permission check
try {
await browserVoiceService.startListening(language, handleSpeechResult, handleSpeechError);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to start voice';
console.error('[useBrowserVoice] Desktop voice start error:', errorMsg);
setError(errorMsg);
setStatus('error');
isActiveRef.current = false;
}
}
}, [isSupported, currentSessionId, language, handleSpeechResult, handleSpeechError, isMobile, unlockServerTTSAudio, unlockSayTTSAudio, sttProvider, sttServerUrl, sttModel, sttApiKey, 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);
finalTranscriptTimerRef.current = null;
}
browserVoiceService.stopListening();
audioStreamService.stopListening();
wasmSttService.stopListening();
browserVoiceService.cancelSpeech();
stopServerTTS(); // Also stop server TTS if playing
stopSayTTS(); // Also stop Say TTS if playing
setStatus('idle');
setError(null);
}, [stopServerTTS, stopSayTTS]);
const finishVoiceInput = useCallback(() => {
if (!isActiveRef.current) {
return;
}
isFinalizingRef.current = true;
pendingResumeOnVisibleRef.current = false;
if (deviceChangeRestartTimerRef.current) {
clearTimeout(deviceChangeRestartTimerRef.current);
deviceChangeRestartTimerRef.current = null;
}
setStatus('processing');
if (sttProvider === 'server') {
void audioStreamService.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');
}, FINAL_TRANSCRIPT_SETTLE_MS + 200);
});
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) {
return;
}
if (pendingFinalTranscriptRef.current || finalTranscriptTimerRef.current) {
return;
}
if (processingMessageRef.current) {
return;
}
isActiveRef.current = false;
processingMessageRef.current = false;
setStatus('idle');
}, FINAL_TRANSCRIPT_SETTLE_MS + 300);
}, [sttProvider]);
// Cleanup on unmount
useEffect(() => {
return () => {
isActiveRef.current = false;
if (deviceChangeRestartTimerRef.current) {
clearTimeout(deviceChangeRestartTimerRef.current);
deviceChangeRestartTimerRef.current = null;
}
pendingFinalTranscriptRef.current = '';
if (finalTranscriptTimerRef.current) {
clearTimeout(finalTranscriptTimerRef.current);
finalTranscriptTimerRef.current = null;
}
browserVoiceService.setConversationMode(false);
browserVoiceService.stopListening();
audioStreamService.stopListening();
browserVoiceService.cancelSpeech();
};
}, []);
return {
status,
isSupported,
error,
language,
setLanguage,
startVoice,
stopVoice,
finishVoiceInput,
conversationMode,
toggleConversationMode,
prepareVoice,
isMobile,
voiceProvider,
};
}