'use client' import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' type VoiceState = 'idle' | 'wake' | 'listening' | 'command' | 'response' | 'error' type Agent = 'cosmo' | 'lusya' interface VoiceEvent { event: VoiceState agent?: Agent text?: string timestamp: string } // Per-agent accent pair (inner core / outer halo). Минималистично, без имён. const AGENT_COLORS: Record = { cosmo: { core: '#a5b4fc', halo: '#7c3aed' }, lusya: { core: '#fbcfe8', halo: '#ec4899' }, } const STATUS_LABEL: Record, string> = { wake: 'слушаю', listening: 'жду', command: '', response: '', error: '', } export default function VoiceOverlay() { const [state, setState] = useState('idle') const [agent, setAgent] = useState('cosmo') const [text, setText] = useState('') const dismissTimer = useRef | null>(null) const audioRef = useRef(null) const audioUrlRef = useRef(null) const clearDismiss = () => { if (dismissTimer.current) { clearTimeout(dismissTimer.current) dismissTimer.current = null } } const scheduleDismiss = (ms: number) => { clearDismiss() dismissTimer.current = setTimeout(() => setState('idle'), ms) } const stopAudio = () => { if (audioRef.current) { try { audioRef.current.pause() audioRef.current.src = '' } catch {} audioRef.current = null } if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current) audioUrlRef.current = null } } const playTTS = async (textToSpeak: string, agentId: Agent, onEnded?: () => void) => { stopAudio() 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) { onEnded?.() return } const blob = await r.blob() const url = URL.createObjectURL(blob) audioUrlRef.current = url const audio = new Audio(url) const finish = () => { if (audioUrlRef.current === url) { URL.revokeObjectURL(url) audioUrlRef.current = null } onEnded?.() } audio.onended = finish audio.onerror = finish audioRef.current = audio try { await audio.play() } catch { finish() } } catch { onEnded?.() } } useEffect(() => { let es: EventSource | null = null let retry: ReturnType | null = null let closedByUs = false // Единый обработчик для SSE и локальных CustomEvent от VoiceController. const handleEvent = (evt: VoiceEvent) => { const currentAgent: Agent = evt.agent ?? agent if (evt.agent) setAgent(evt.agent) // Safety: если событийный поток заглохнет, через 60с сами закроемся. const armSafety = () => scheduleDismiss(60_000) if (evt.event === 'wake') { stopAudio() setState('wake') setText('') armSafety() } else if (evt.event === 'listening') { setState('listening') armSafety() } else if (evt.event === 'command') { setState('command') setText(evt.text || '') armSafety() } else if (evt.event === 'response') { setState('response') setText(evt.text || '') clearDismiss() if (evt.text) { playTTS(evt.text, currentAgent, () => scheduleDismiss(4000)) } else { scheduleDismiss(4000) } } else if (evt.event === 'error') { setState('error') setText(evt.text || 'Ошибка') clearDismiss() if (evt.text) { playTTS(evt.text, currentAgent, () => scheduleDismiss(3000)) } else { scheduleDismiss(3000) } } else if (evt.event === 'idle') { clearDismiss() stopAudio() setState('idle') } } const connect = () => { es = new EventSource('/api/voice/stream') es.onmessage = (e) => { try { handleEvent(JSON.parse(e.data) as VoiceEvent) } catch {} } es.onerror = () => { if (closedByUs) return es?.close() retry = setTimeout(connect, 3000) } } connect() // Локальные события (push-to-talk до round-trip на сервер). const onLocal = (e: Event) => { const detail = (e as CustomEvent).detail if (detail) handleEvent(detail) } window.addEventListener('voice-local', onLocal as EventListener) return () => { closedByUs = true clearDismiss() stopAudio() if (retry) clearTimeout(retry) es?.close() window.removeEventListener('voice-local', onLocal as EventListener) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const isActive = state !== 'idle' const colors = AGENT_COLORS[agent] const status = state !== 'idle' ? STATUS_LABEL[state] : '' return ( {isActive && ( {/* Subtle status (только "слушаю" — для остальных текст сам говорит за себя) */} {status && ( {status} )} {/* Текст — распознанный / ответ */} {text && ( {text} )} )} ) } function SiriOrb({ core, halo, state }: { core: string; halo: string; state: VoiceState }) { const isIntense = state === 'wake' const isListening = state === 'listening' const isResponding = state === 'response' return (
{/* Outer halo — медленное дыхание */} {/* Inner ring — быстрее, с подкрученным blur */} {/* Bright core — тонкий highlight */}
) }