'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 } const AGENT_STYLE: Record = { cosmo: { primary: '#818cf8', secondary: '#a855f7', name: 'Cosmo', emoji: '🦞' }, lusya: { primary: '#ec4899', secondary: '#f43f5e', name: 'Люся', emoji: '👩' }, } export default function VoiceOverlay() { const [state, setState] = useState('idle') const [agent, setAgent] = useState('cosmo') const [text, setText] = useState('') const dismissTimer = useRef | null>(null) const clearDismiss = () => { if (dismissTimer.current) { clearTimeout(dismissTimer.current) dismissTimer.current = null } } const scheduleDismiss = (ms: number) => { clearDismiss() dismissTimer.current = setTimeout(() => setState('idle'), ms) } const audioRef = useRef(null) const audioUrlRef = useRef(null) 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) { console.warn('TTS endpoint error:', r.status) 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(err => { console.warn('Audio autoplay blocked:', err) }) } catch (err) { console.warn('TTS fetch failed:', err) } } 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') { // Barge-in: cut any ongoing TTS when user speaks again 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() } // agent is intentionally omitted — we always read from ref via the evt // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const isActive = state !== 'idle' const style = AGENT_STYLE[agent] return ( {isActive && (
{style.emoji} {style.name} {state !== 'wake' && ( )} {state === 'wake' ? '· слушает' : state === 'command' ? '· распознал' : state === 'response' ? '· отвечает' : state === 'error' ? '· ошибка' : ''}
{state === 'wake' ? 'Слушаю…' : (text || '…')}
)}
) } function SiriBlob({ color, color2, state }: { color: string; color2: string; state: VoiceState }) { const isIntense = state === 'wake' return (
{/* Outer pulsing ring */} {/* Inner core */} {/* Bright center dot */}
) }