// VoiceOutput — speaker button that reads text aloud via the browser's // SpeechSynthesis API. Same shape as main project's voice-output.tsx, // ported to the MVP's no-Tailwind / no-framer-motion stack. // // Props: // text — string to speak. Markdown is stripped before TTS. // autoPlay? — speak the text once when it first appears (used for the // voice-in → voice-out chat flow). const _VOIcon = ({ d, size = 14 }) => ( {typeof d === 'string' ? : d} ); const IconSpeaker = (p) => <_VOIcon {...p} d={<>}/>; const IconStop2 = (p) => <_VOIcon {...p} d={}/>; // Strip markdown so speech sounds natural (no "asterisk asterisk bold"). function _stripMd(md) { return String(md || '') .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]+)`/g, '$1') .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/^#{1,6}\s+/gm, '') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/_([^_]+)_/g, '$1') .replace(/^\s*[-*+]\s+/gm, '') .replace(/^\s*\d+\.\s+/gm, '') .replace(/\|/g, ' ') .replace(/---+/g, ' ') .replace(/\s+/g, ' ') .trim(); } // Pick the best installed voice for a BCP-47 code (exact match first, then // base language, e.g. "ta-IN" → fall back to any "ta-*" voice). function _pickVoice(code) { if (typeof window === 'undefined' || !window.speechSynthesis) return null; const voices = window.speechSynthesis.getVoices(); if (!voices.length) return null; const exact = voices.find(v => v.lang && v.lang.toLowerCase() === code.toLowerCase()); if (exact) return exact; const base = code.split('-')[0].toLowerCase(); return voices.find(v => v.lang && v.lang.toLowerCase().startsWith(base)) || null; } const VoiceOutput = ({ text, autoPlay = false }) => { const lang = window.useLanguage(); const t = lang.t; const [speaking, setSpeaking] = React.useState(false); const utterRef = React.useRef(null); const autoPlayedRef = React.useRef(false); const supported = typeof window !== 'undefined' && 'speechSynthesis' in window; // Voices load asynchronously in some browsers (Chrome). Force load on mount // and listen for the voiceschanged event. React.useEffect(() => { if (!supported) return; window.speechSynthesis.getVoices(); // trigger const onChange = () => window.speechSynthesis.getVoices(); window.speechSynthesis.onvoiceschanged = onChange; return () => { try { window.speechSynthesis.onvoiceschanged = null; } catch {} }; }, [supported]); const stop = () => { if (!supported) return; window.speechSynthesis.cancel(); setSpeaking(false); utterRef.current = null; }; const speak = React.useCallback(() => { if (!supported) return; const clean = _stripMd(text); if (!clean) return; window.speechSynthesis.cancel(); // never overlap const code = (lang.current && lang.current.speechCode) || 'en-US'; const utter = new SpeechSynthesisUtterance(clean); utter.lang = code; const v = _pickVoice(code); if (v) utter.voice = v; utter.rate = 1.0; utter.pitch = 1.0; utter.onstart = () => setSpeaking(true); utter.onend = () => { setSpeaking(false); utterRef.current = null; }; utter.onerror = () => { setSpeaking(false); utterRef.current = null; }; utterRef.current = utter; if (!window.speechSynthesis.getVoices().length) { // Voices not loaded yet — wait briefly then speak. setTimeout(() => { const v2 = _pickVoice(code); if (v2) utter.voice = v2; window.speechSynthesis.speak(utter); }, 250); return; } window.speechSynthesis.speak(utter); }, [text, lang, supported]); const toggle = () => { if (speaking) stop(); else speak(); }; // Auto-play once per text change when enabled. React.useEffect(() => { if (autoPlay && !autoPlayedRef.current && text) { autoPlayedRef.current = true; const id = setTimeout(speak, 200); return () => clearTimeout(id); } }, [autoPlay, text, speak]); // Cancel speech on unmount. React.useEffect(() => () => { if (utterRef.current && supported) { try { window.speechSynthesis.cancel(); } catch {} } }, [supported]); if (!supported) return null; return ( ); }; window.VoiceOutput = VoiceOutput;