diff --git a/components/VoiceController.tsx b/components/VoiceController.tsx index 2f4a1cf..26facab 100644 --- a/components/VoiceController.tsx +++ b/components/VoiceController.tsx @@ -38,6 +38,8 @@ export default function VoiceController() { const wakeRef = useRef(null) const vadRef = useRef(null) const busyRef = useRef(false) + const followUpRef = useRef(false) + const followUpTimerRef = useRef | null>(null) useEffect(() => { vlog('[VoiceController] mounted, state=idle, ждём тап на микрофон') @@ -46,6 +48,8 @@ export default function VoiceController() { // (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию). const onCancel = () => { vlog('[voice] cancel — пауза VAD') + followUpRef.current = false + if (followUpTimerRef.current) { clearTimeout(followUpTimerRef.current); followUpTimerRef.current = null } try { vadRef.current?.pause?.() } catch {} busyRef.current = false try { wakeRef.current?.resume?.() } catch {} @@ -54,8 +58,36 @@ export default function VoiceController() { } window.addEventListener('voice-cancel', onCancel) + const onFollowUp = () => { + if (!wakeRef.current) return // ассистент выключен + vlog('[voice] follow-up window открыто (8s)') + followUpRef.current = true + // Очистить предыдущий таймер если был + if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current) + // Запустить VAD сразу без wake word + if (vadRef.current) { + try { vadRef.current.start() } catch {} + } + setState('recording') + emitLocal('wake', AGENT) + // Через 8 секунд — если никто не заговорил — вернуться к wake + followUpTimerRef.current = setTimeout(() => { + if (followUpRef.current) { + followUpRef.current = false + vlog('[voice] follow-up timeout — возврат к wake word') + try { vadRef.current?.pause?.() } catch {} + try { wakeRef.current?.resume?.() } catch {} + setState('listening') + emitLocal('idle', AGENT) + } + }, 8000) + } + window.addEventListener('voice-follow-up', onFollowUp) + return () => { window.removeEventListener('voice-cancel', onCancel) + window.removeEventListener('voice-follow-up', onFollowUp) + if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current) try { vadRef.current?.destroy?.() } catch {} try { wakeRef.current?.stop?.() } catch {} vadRef.current = null @@ -96,11 +128,18 @@ export default function VoiceController() { emitLocal('error', AGENT, 'Не получилось') } finally { busyRef.current = false - // VAD на паузу — переиспользуем при следующем wake (без re-init). - try { vadRef.current?.pause?.() } catch {} - // Wake возобновляем — снова слушаем фоном. - try { wakeRef.current?.resume?.() } catch {} - setState((s) => (s === 'busy' ? 'listening' : s)) + // Если активен follow-up режим — НЕ переключаемся на wake, ответ сам откроет новое окно + if (!followUpRef.current) { + try { vadRef.current?.pause?.() } catch {} + try { wakeRef.current?.resume?.() } catch {} + setState((s) => (s === 'busy' ? 'listening' : s)) + } + // Сбросить follow-up флаг — новое окно откроется после TTS + followUpRef.current = false + if (followUpTimerRef.current) { + clearTimeout(followUpTimerRef.current) + followUpTimerRef.current = null + } } } diff --git a/components/VoiceOverlay.tsx b/components/VoiceOverlay.tsx index f0a8b27..4772896 100644 --- a/components/VoiceOverlay.tsx +++ b/components/VoiceOverlay.tsx @@ -172,7 +172,11 @@ export default function VoiceOverlay() { setText(evt.text || '') clearDismiss() if (evt.text) { - playTTS(evt.text, currentAgent, () => scheduleDismiss(4000)) + playTTS(evt.text, currentAgent, () => { + // Сигнализируем VoiceController что можно слушать follow-up + window.dispatchEvent(new CustomEvent('voice-follow-up')) + scheduleDismiss(9000) + }) } else { scheduleDismiss(4000) }