| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { useCallback, useEffect, useRef, useState } from 'react'; |
| import { createVAD } from './vad'; |
| const DEFAULT_CONFIG = { |
| ttsEndDelay: 300, |
| bargeInEnabled: true, |
| postTtsMicGuardMs: 650, |
| vadConfig: { |
| baseThreshold: 0.035, |
| hysteresisHigh: 1.8, |
| hysteresisLow: 0.9, |
| minSpeechMs: 200, |
| silenceMs: 800, |
| }, |
| }; |
| export function useVoiceController(onSendText, config) { |
| const cfg = { ...DEFAULT_CONFIG, ...config }; |
| const svc = window.SpeechService; |
| |
| const sttSupported = typeof window !== 'undefined' && |
| |
| (!!window.SpeechRecognition || !!window.webkitSpeechRecognition); |
| |
| const [state, setState] = useState('OFF'); |
| const [isHandsFree, setIsHandsFree] = useState(() => { |
| |
| const saved = localStorage.getItem('homepilot_voice_handsfree'); |
| return saved === null || saved === 'true'; |
| }); |
| const [isTtsEnabled, setIsTtsEnabled] = useState(() => { |
| return localStorage.getItem('homepilot_tts_enabled') !== 'false'; |
| }); |
| const [interimText, setInterimText] = useState(''); |
| |
| const [lastError, setLastError] = useState(null); |
| |
| const [audioLevel, setAudioLevel] = useState(0); |
| const [noiseFloor, setNoiseFloor] = useState(0); |
| const [threshold, setThreshold] = useState(0); |
| |
| const [voices, setVoices] = useState([]); |
| const [selectedVoice, setSelectedVoiceState] = useState(() => { |
| return localStorage.getItem('homepilot_voice_uri') || ''; |
| }); |
| |
| const vadRef = useRef(null); |
| const stateRef = useRef(state); |
| const ttsEndTimeoutRef = useRef(null); |
| const thinkingTimeoutRef = useRef(null); |
| const pendingResultRef = useRef(false); |
| const lastSttEndRef = useRef(0); |
| |
| |
| |
| const postTtsMicGuardUntilRef = useRef(0); |
| |
| |
| |
| |
| |
| |
| const handsFreeGenerationRef = useRef(0); |
| |
| |
| |
| |
| |
| const listeningSuppressedRef = useRef(false); |
| |
| useEffect(() => { |
| stateRef.current = state; |
| }, [state]); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (state === 'THINKING' && isHandsFree) { |
| thinkingTimeoutRef.current = setTimeout(() => { |
| if (stateRef.current === 'THINKING') { |
| console.warn('[VoiceController] THINKING timeout - returning to IDLE'); |
| setState('IDLE'); |
| } |
| }, 4000); |
| } |
| else { |
| |
| if (thinkingTimeoutRef.current) { |
| clearTimeout(thinkingTimeoutRef.current); |
| thinkingTimeoutRef.current = null; |
| } |
| } |
| return () => { |
| if (thinkingTimeoutRef.current) { |
| clearTimeout(thinkingTimeoutRef.current); |
| thinkingTimeoutRef.current = null; |
| } |
| }; |
| }, [state, isHandsFree]); |
| |
| useEffect(() => { |
| localStorage.setItem('homepilot_tts_enabled', String(isTtsEnabled)); |
| }, [isTtsEnabled]); |
| |
| useEffect(() => { |
| if (!svc) |
| return; |
| const loadVoices = () => { |
| const availableVoices = svc.getVoices?.() || []; |
| setVoices(availableVoices); |
| if (!selectedVoice && availableVoices.length > 0) { |
| |
| const defaultVoice = availableVoices.find((v) => v.name.toLowerCase().includes('google') && v.lang.startsWith('en')) || availableVoices.find((v) => v.default) |
| || availableVoices[0]; |
| const voiceURI = defaultVoice.voiceURI; |
| setSelectedVoiceState(voiceURI); |
| localStorage.setItem('homepilot_voice_uri', voiceURI); |
| } |
| }; |
| loadVoices(); |
| if (typeof window !== 'undefined' && window.speechSynthesis) { |
| window.speechSynthesis.onvoiceschanged = loadVoices; |
| } |
| }, [svc, selectedVoice]); |
| |
| useEffect(() => { |
| if (!svc || !selectedVoice) |
| return; |
| svc.setPreferredVoiceURI?.(selectedVoice); |
| }, [svc, selectedVoice]); |
| const setSelectedVoice = useCallback((voiceURI) => { |
| setSelectedVoiceState(voiceURI); |
| localStorage.setItem('homepilot_voice_uri', voiceURI); |
| }, []); |
| |
| |
| |
| useEffect(() => { |
| if (!svc) |
| return; |
| let lastTTSState = false; |
| const checkTTSState = () => { |
| const isSpeaking = svc.isSpeaking || false; |
| if (isSpeaking !== lastTTSState) { |
| lastTTSState = isSpeaking; |
| if (isSpeaking) { |
| |
| console.log('[VoiceController] TTS started - state: SPEAKING'); |
| setState('SPEAKING'); |
| |
| if (vadRef.current?.isRunning() && !vadRef.current.isPaused()) { |
| vadRef.current.pause(); |
| |
| |
| |
| setAudioLevel(0); |
| console.log('[VoiceController] VAD paused during TTS'); |
| } |
| |
| if (ttsEndTimeoutRef.current) { |
| clearTimeout(ttsEndTimeoutRef.current); |
| ttsEndTimeoutRef.current = null; |
| } |
| } |
| else { |
| |
| console.log('[VoiceController] TTS ended - waiting before state transition'); |
| ttsEndTimeoutRef.current = setTimeout(() => { |
| |
| |
| |
| |
| |
| postTtsMicGuardUntilRef.current = |
| Date.now() + (cfg.postTtsMicGuardMs ?? 0); |
| |
| if (vadRef.current?.isRunning() && vadRef.current.isPaused()) { |
| vadRef.current.resume(); |
| |
| |
| setAudioLevel(0); |
| console.log('[VoiceController] VAD resumed after TTS'); |
| } |
| if (stateRef.current === 'SPEAKING') { |
| |
| |
| const nextState = isHandsFree ? 'IDLE' : 'OFF'; |
| setState(nextState); |
| console.log(`[VoiceController] Transitioned to ${nextState} after TTS`); |
| } |
| ttsEndTimeoutRef.current = null; |
| }, cfg.ttsEndDelay); |
| } |
| } |
| }; |
| |
| const interval = setInterval(checkTTSState, 50); |
| return () => { |
| clearInterval(interval); |
| if (ttsEndTimeoutRef.current) { |
| clearTimeout(ttsEndTimeoutRef.current); |
| ttsEndTimeoutRef.current = null; |
| } |
| }; |
| }, [svc, isHandsFree, cfg.ttsEndDelay, cfg.postTtsMicGuardMs]); |
| |
| useEffect(() => { |
| if (!svc) |
| return; |
| svc.setRecognitionCallbacks({ |
| onStart: () => { |
| console.log('[VoiceController] STT started - state: LISTENING'); |
| setLastError(null); |
| pendingResultRef.current = false; |
| setState('LISTENING'); |
| }, |
| onEnd: () => { |
| console.log('[VoiceController] STT ended, hadResult:', pendingResultRef.current); |
| |
| lastSttEndRef.current = Date.now(); |
| |
| if (stateRef.current === 'LISTENING') { |
| if (isHandsFree) { |
| |
| |
| const nextState = pendingResultRef.current ? 'THINKING' : 'IDLE'; |
| console.log(`[VoiceController] STT ended -> ${nextState}`); |
| setState(nextState); |
| } |
| else { |
| setState('OFF'); |
| } |
| } |
| pendingResultRef.current = false; |
| }, |
| onInterim: (text) => { |
| setInterimText(text); |
| }, |
| onResult: (finalText) => { |
| setInterimText(''); |
| if (finalText?.trim()) { |
| console.log('[VoiceController] STT result:', finalText.trim()); |
| pendingResultRef.current = true; |
| onSendText(finalText.trim()); |
| |
| if (isHandsFree) { |
| setState('THINKING'); |
| } |
| } |
| }, |
| onError: (msg) => { |
| console.warn('[VoiceController] STT error:', msg); |
| setLastError(msg || 'stt_error'); |
| pendingResultRef.current = false; |
| if (isHandsFree) { |
| setState('IDLE'); |
| } |
| else { |
| setState('OFF'); |
| } |
| }, |
| }); |
| }, [svc, onSendText, isHandsFree]); |
| |
| useEffect(() => { |
| |
| |
| |
| |
| const generation = ++handsFreeGenerationRef.current; |
| if (!isHandsFree || !svc) { |
| |
| if (vadRef.current) { |
| vadRef.current.stop(); |
| vadRef.current = null; |
| } |
| if (!isHandsFree) { |
| setState('OFF'); |
| } |
| return; |
| } |
| |
| if (!sttSupported) { |
| setLastError('stt_not_supported'); |
| setState('OFF'); |
| return; |
| } |
| |
| |
| |
| |
| |
| |
| |
| const vad = createVAD( |
| |
| () => { |
| |
| if (!isHandsFree || generation !== handsFreeGenerationRef.current) { |
| return; |
| } |
| const currentState = stateRef.current; |
| console.log('[VoiceController] VAD speech start, current state:', currentState); |
| |
| if (listeningSuppressedRef.current) { |
| console.log('[VoiceController] VAD speech start suppressed (turn lock)'); |
| return; |
| } |
| |
| |
| if (Date.now() < postTtsMicGuardUntilRef.current) { |
| console.log('[VoiceController] Ignoring speech start during post-TTS mic guard'); |
| return; |
| } |
| |
| if (currentState === 'SPEAKING' && cfg.bargeInEnabled) { |
| console.log('[VoiceController] Barge-in detected - stopping TTS'); |
| svc.stopSpeaking?.(); |
| } |
| |
| const timeSinceLastEnd = Date.now() - lastSttEndRef.current; |
| const cooldownMs = 300; |
| if (timeSinceLastEnd < cooldownMs) { |
| console.log('[VoiceController] STT cooldown active, waiting...'); |
| |
| setTimeout(() => { |
| if (stateRef.current === 'IDLE') { |
| console.log('[VoiceController] Starting STT after cooldown'); |
| try { |
| svc.startSTT?.({}); |
| } |
| catch (e) { |
| const msg = e instanceof Error ? e.message : 'stt_start_failed'; |
| setLastError(msg); |
| setState('OFF'); |
| } |
| } |
| }, cooldownMs - timeSinceLastEnd); |
| return; |
| } |
| |
| if (currentState === 'IDLE' || currentState === 'SPEAKING') { |
| try { |
| svc.startSTT?.({}); |
| } |
| catch (e) { |
| const msg = e instanceof Error ? e.message : 'stt_start_failed'; |
| setLastError(msg); |
| setState('OFF'); |
| } |
| } |
| }, |
| |
| () => { |
| if (!isHandsFree || generation !== handsFreeGenerationRef.current) { |
| return; |
| } |
| const currentState = stateRef.current; |
| console.log('[VoiceController] VAD speech end, current state:', currentState); |
| |
| |
| |
| |
| if (listeningSuppressedRef.current) { |
| console.log('[VoiceController] VAD speech end suppressed (turn lock)'); |
| return; |
| } |
| |
| |
| if (currentState === 'LISTENING') { |
| setTimeout(() => { |
| |
| if (stateRef.current === 'LISTENING') { |
| try { |
| console.log('[VoiceController] Stopping STT after delay'); |
| svc.stopSTT?.(); |
| } |
| catch (e) { |
| const msg = e instanceof Error ? e.message : 'stt_stop_failed'; |
| setLastError(msg); |
| } |
| } |
| }, 400); |
| } |
| }, cfg.vadConfig); |
| vadRef.current = vad; |
| |
| vad.start() |
| .then(() => { |
| |
| if (!isHandsFree || generation !== handsFreeGenerationRef.current) { |
| vad.stop(); |
| return; |
| } |
| console.log('[VoiceController] VAD started, transitioning to IDLE'); |
| setLastError(null); |
| setState('IDLE'); |
| }) |
| .catch((err) => { |
| |
| |
| if (generation !== handsFreeGenerationRef.current) |
| return; |
| console.error('[VoiceController] VAD start failed:', err); |
| setLastError(err?.message || 'vad_start_failed'); |
| setState('OFF'); |
| }); |
| return () => { |
| if (vadRef.current) { |
| vadRef.current.stop(); |
| vadRef.current = null; |
| } |
| }; |
| }, [isHandsFree, svc, sttSupported, cfg.vadConfig, cfg.bargeInEnabled]); |
| |
| useEffect(() => { |
| if (!vadRef.current || !isHandsFree) |
| return; |
| const updateLevels = () => { |
| if (vadRef.current) { |
| setAudioLevel(vadRef.current.getCurrentLevel()); |
| setNoiseFloor(vadRef.current.getNoiseFloor()); |
| setThreshold(vadRef.current.getThreshold()); |
| } |
| }; |
| const interval = setInterval(updateLevels, 100); |
| return () => clearInterval(interval); |
| }, [isHandsFree]); |
| |
| const clearError = useCallback(() => { |
| setLastError(null); |
| }, []); |
| |
| const startManualListening = useCallback(() => { |
| if (!svc) |
| return; |
| |
| if (!sttSupported) { |
| setLastError('stt_not_supported'); |
| setState('OFF'); |
| return; |
| } |
| svc.stopSpeaking?.(); |
| try { |
| svc.startSTT?.({}); |
| } |
| catch (e) { |
| const msg = e instanceof Error ? e.message : 'stt_start_failed'; |
| setLastError(msg); |
| setState('OFF'); |
| } |
| }, [svc, sttSupported]); |
| const stopManualListening = useCallback(() => { |
| if (!svc) |
| return; |
| try { |
| svc.stopSTT?.(); |
| } |
| catch (e) { |
| const msg = e instanceof Error ? e.message : 'stt_stop_failed'; |
| setLastError(msg); |
| } |
| }, [svc]); |
| const stopSpeaking = useCallback(() => { |
| if (!svc) |
| return; |
| svc.stopSpeaking?.(); |
| |
| if (vadRef.current?.isRunning() && vadRef.current.isPaused()) { |
| vadRef.current.resume(); |
| } |
| |
| setState(isHandsFree ? 'IDLE' : 'OFF'); |
| }, [svc, isHandsFree]); |
| const setHandsFree = useCallback((enabled) => { |
| setIsHandsFree(enabled); |
| localStorage.setItem('homepilot_voice_handsfree', String(enabled)); |
| if (!enabled) { |
| setState('OFF'); |
| } |
| }, []); |
| const setTtsEnabled = useCallback((enabled) => { |
| setIsTtsEnabled(enabled); |
| }, []); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const setListeningSuppressed = useCallback((suppressed, reason = 'unspecified') => { |
| listeningSuppressedRef.current = suppressed; |
| console.log(`[VoiceController] Listening suppression ${suppressed ? 'enabled' : 'disabled'} (reason=${reason})`); |
| if (suppressed && stateRef.current === 'LISTENING') { |
| try { |
| svc?.stopSTT?.(); |
| } |
| catch { } |
| setState(isHandsFree ? 'IDLE' : 'OFF'); |
| } |
| }, [svc, isHandsFree]); |
| return { |
| |
| state, |
| isHandsFree, |
| isTtsEnabled, |
| interimText, |
| audioLevel, |
| noiseFloor, |
| threshold, |
| |
| sttSupported, |
| lastError, |
| clearError, |
| |
| setHandsFree, |
| setTtsEnabled, |
| startManualListening, |
| stopManualListening, |
| stopSpeaking, |
| setListeningSuppressed, |
| |
| voices, |
| selectedVoice, |
| setSelectedVoice, |
| }; |
| } |
|
|