/** Text-to-speech through the browser's built-in voices; no audio files or services. */ const supported = () => typeof window !== 'undefined' && 'speechSynthesis' in window; function voices(): Promise { if (!supported()) return Promise.resolve([]); const now = speechSynthesis.getVoices(); if (now.length) return Promise.resolve(now); return new Promise((resolve) => { const done = () => resolve(speechSynthesis.getVoices()); speechSynthesis.addEventListener('voiceschanged', done, { once: true }); setTimeout(done, 1500); }); } const matches = (voice: SpeechSynthesisVoice, lang: string) => voice.lang.replace('_', '-').toLowerCase().startsWith(lang.slice(0, 2).toLowerCase()); function best(list: SpeechSynthesisVoice[], lang: string) { const candidates = list.filter((v) => matches(v, lang)); return ( candidates.find( (v) => v.lang.replace('_', '-').toLowerCase() === lang.toLowerCase() && !v.localService ) ?? candidates.find((v) => v.lang.replace('_', '-').toLowerCase() === lang.toLowerCase()) ?? candidates[0] ); } export async function hasVoice(lang: string): Promise { return !!best(await voices(), lang); } export async function speak(text: string, lang: string, rate = 0.9) { if (!supported()) return; const voice = best(await voices(), lang); speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = lang; utterance.rate = rate; if (voice) utterance.voice = voice; speechSynthesis.speak(utterance); }