'use client' import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X } from 'lucide-react' 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 audioSourceRef = 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 (audioSourceRef.current) { try { audioSourceRef.current.stop() } catch {} try { audioSourceRef.current.disconnect() } catch {} audioSourceRef.current = null } 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() // Сначала пытаемся через общий AudioContext (он разблокирован в start() // VoiceController при тапе пользователя). На iOS Safari это единственный // надёжный путь; на остальных тоже работает. const ctx: AudioContext | undefined = (window as any).__voicePlaybackCtx if (ctx) { try { if (ctx.state === 'suspended') await ctx.resume() const arrayBuf = await blob.arrayBuffer() const audioBuf: AudioBuffer = await new Promise((resolve, reject) => { // decodeAudioData в Safari исторически callback-API, поддерживает оба. try { const p = ctx.decodeAudioData(arrayBuf, resolve, reject) as any if (p && typeof p.then === 'function') p.then(resolve, reject) } catch (e) { reject(e) } }) const source = ctx.createBufferSource() source.buffer = audioBuf source.connect(ctx.destination) const finish = () => { if (audioSourceRef.current === source) audioSourceRef.current = null onEnded?.() } source.onended = finish audioSourceRef.current = source source.start() return } catch (e: any) { console.warn('[voice] AudioContext playback failed, fallback to