'use client' import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' type VoiceState = 'idle' | 'wake' | '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: 'слушаю', 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) => { stopAudio() if (!textToSpeak) 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 const blob = await r.blob() const url = URL.createObjectURL(blob) audioUrlRef.current = url const audio = new Audio(url) audio.onended = () => { if (audioUrlRef.current === url) { URL.revokeObjectURL(url) audioUrlRef.current = null } } audioRef.current = audio await audio.play().catch(() => {}) } catch {} } useEffect(() => { let es: EventSource | null = null let retry: ReturnType | null = null let closedByUs = false const connect = () => { es = new EventSource('/api/voice/stream') es.onmessage = (e) => { try { const evt: VoiceEvent = JSON.parse(e.data) const currentAgent: Agent = evt.agent ?? agent if (evt.agent) setAgent(evt.agent) if (evt.event === 'wake') { stopAudio() setState('wake') setText('') scheduleDismiss(20000) } else if (evt.event === 'command') { setState('command') setText(evt.text || '') scheduleDismiss(30000) } 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)) } else if (evt.event === 'error') { setState('error') setText(evt.text || 'Ошибка') if (evt.text) playTTS(evt.text, currentAgent) scheduleDismiss(5000) } else if (evt.event === 'idle') { clearDismiss() setState('idle') } } catch {} } es.onerror = () => { if (closedByUs) return es?.close() retry = setTimeout(connect, 3000) } } connect() return () => { closedByUs = true clearDismiss() stopAudio() if (retry) clearTimeout(retry) es?.close() } // 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 isResponding = state === 'response' return (
{/* Outer halo — медленное дыхание */} {/* Inner ring — быстрее, с подкрученным blur */} {/* Bright core — тонкий highlight */}
) }