diff --git a/components/VoiceOverlay.tsx b/components/VoiceOverlay.tsx index 8888910..5536357 100644 --- a/components/VoiceOverlay.tsx +++ b/components/VoiceOverlay.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -type VoiceState = 'idle' | 'wake' | 'command' | 'response' | 'error' +type VoiceState = 'idle' | 'wake' | 'listening' | 'command' | 'response' | 'error' type Agent = 'cosmo' | 'lusya' interface VoiceEvent { @@ -20,6 +20,7 @@ const AGENT_COLORS: Record = { const STATUS_LABEL: Record, string> = { wake: 'слушаю', + listening: 'жду', command: '', response: '', error: '', @@ -59,29 +60,44 @@ export default function VoiceOverlay() { } } - const playTTS = async (textToSpeak: string, agentId: Agent) => { + const playTTS = async (textToSpeak: string, agentId: Agent, onEnded?: () => void) => { stopAudio() - if (!textToSpeak) return + if (!textToSpeak) { + onEnded?.() + return + } try { const r = await fetch('/api/voice/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: textToSpeak, agent: agentId }), }) - if (!r.ok) return + if (!r.ok) { + onEnded?.() + return + } const blob = await r.blob() const url = URL.createObjectURL(blob) audioUrlRef.current = url const audio = new Audio(url) - audio.onended = () => { + const finish = () => { if (audioUrlRef.current === url) { URL.revokeObjectURL(url) audioUrlRef.current = null } + onEnded?.() } + audio.onended = finish + audio.onerror = finish audioRef.current = audio - await audio.play().catch(() => {}) - } catch {} + try { + await audio.play() + } catch { + finish() + } + } catch { + onEnded?.() + } } useEffect(() => { @@ -98,27 +114,47 @@ export default function VoiceOverlay() { const currentAgent: Agent = evt.agent ?? agent if (evt.agent) setAgent(evt.agent) + // Safety: если Python упадёт и не пришлёт idle, через 60с сами закроемся. + const armSafety = () => scheduleDismiss(60_000) + if (evt.event === 'wake') { + // Свежая активация: barge-in аудио, чистим текст. stopAudio() setState('wake') setText('') - scheduleDismiss(20000) + armSafety() + } else if (evt.event === 'listening') { + // Follow-up: сохраняем последний текст, орб мягко пульсирует. + setState('listening') + armSafety() } else if (evt.event === 'command') { setState('command') setText(evt.text || '') - scheduleDismiss(30000) + armSafety() } else if (evt.event === 'response') { setState('response') setText(evt.text || '') - if (evt.text) playTTS(evt.text, currentAgent) - scheduleDismiss(Math.max(6000, (evt.text?.length || 0) * 80)) + // Dismiss ТОЛЬКО когда аудио доиграло (не по таймеру!). + // После окончания даём Python шанс прислать listening/wake/command — тогда + // scheduleDismiss перезатрётся. Иначе через 4с автозакрытие. + clearDismiss() + if (evt.text) { + playTTS(evt.text, currentAgent, () => scheduleDismiss(4000)) + } else { + scheduleDismiss(4000) + } } else if (evt.event === 'error') { setState('error') setText(evt.text || 'Ошибка') - if (evt.text) playTTS(evt.text, currentAgent) - scheduleDismiss(5000) + clearDismiss() + if (evt.text) { + playTTS(evt.text, currentAgent, () => scheduleDismiss(3000)) + } else { + scheduleDismiss(3000) + } } else if (evt.event === 'idle') { clearDismiss() + stopAudio() setState('idle') } } catch {} @@ -213,6 +249,7 @@ export default function VoiceOverlay() { function SiriOrb({ core, halo, state }: { core: string; halo: string; state: VoiceState }) { const isIntense = state === 'wake' + const isListening = state === 'listening' const isResponding = state === 'response' return ( @@ -220,11 +257,11 @@ function SiriOrb({ core, halo, state }: { core: string; halo: string; state: Voi {/* Outer halo — медленное дыхание */}