// Read-aloud for an ESL writer: hear how an English word or passage sounds. // // Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural, // consistent across devices, and far better than the browser's built-in voices // (which fall back to robotic espeak on Chromium). If the server route is absent // (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so // the buttons still do something. No model or network is strictly required. // speechSupported reports whether read-aloud can do anything at all. Audio // playback is universal, so as long as we can construct an Audio element OR the // Web Speech API exists, the buttons should show. The server path is tried at // call time and degrades on its own. export function speechSupported(): boolean { if (typeof window === 'undefined') return false return typeof window.Audio === 'function' || 'speechSynthesis' in window } // webSpeechSupported gates the fallback path specifically. function webSpeechSupported(): boolean { return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window } // current holds the in-flight server-audio element and its object URL so a rapid // re-tap can stop and replace it (mirroring speechSynthesis.cancel()). let current: { audio: HTMLAudioElement; url: string } | null = null // requestSeq increments on every speak() call so a slow fetch that resolves after // a newer tap can detect it's stale and bow out instead of double-playing. let requestSeq = 0 // stopSpeech halts any read-aloud in flight — both the server-audio element and // the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows // out instead of playing late. Exported so a panel can cancel audio on unmount, // keeping a word's pronunciation from outliving the panel that started it. export function stopSpeech(): void { requestSeq++ if (current) { current.audio.pause() URL.revokeObjectURL(current.url) current = null } if (webSpeechSupported()) window.speechSynthesis.cancel() } // pickVoice prefers an exact locale match (e.g. en-US), then any voice in the // same language family (en-*). Returns undefined to let the engine default. function pickVoice(lang: string): SpeechSynthesisVoice | undefined { const voices = window.speechSynthesis.getVoices() const base = lang.split('-')[0] return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base)) } // speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch // slower than default so learners can follow along. function speakWebSpeech(text: string, lang: string): void { if (!webSpeechSupported()) return const synth = window.speechSynthesis synth.cancel() const utterance = new SpeechSynthesisUtterance(text) utterance.lang = lang const voice = pickVoice(lang) if (voice) utterance.voice = voice utterance.rate = 0.95 synth.speak(utterance) } // detectLang guesses a BCP-47 locale from the text so the right Piper voice is // chosen. Any Han/kana character routes to Chinese (zh-CN) — a selection is // essentially never mixed at the level that matters, and reading a Chinese // passage with the English voice spells each character out ("Chinese letter…"). // Everything else defaults to US English, the language she's learning. const CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿ヲ-゚]/ export function detectLang(text: string): string { return CJK.test(text) ? 'zh-CN' : 'en-US' } // speak reads `text` aloud, cancelling anything already in flight so rapid taps // don't queue up. `lang` defaults to a guess from the text (Chinese vs English) // so callers can just pass the selection; pass an explicit locale to override. // It tries the server's neural voice first and silently falls back to the browser // voice if that's unavailable (route off, network error, or a 404 for a language // with no configured voice). export function speak(text: string, lang = detectLang(text)): void { if (!text.trim()) return stopSpeech() const seq = ++requestSeq fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, lang }), }) .then((res) => { if (!res.ok) throw new Error(`tts ${res.status}`) return res.blob() }) .then((blob) => { // A newer tap superseded this one while the fetch was in flight — drop it. if (seq !== requestSeq) return const url = URL.createObjectURL(blob) const audio = new Audio(url) current = { audio, url } // Release the blob once playback finishes (or errors) to avoid leaking. const cleanup = () => { if (current?.audio === audio) { URL.revokeObjectURL(url) current = null } } audio.addEventListener('ended', cleanup) audio.addEventListener('error', cleanup) return audio.play() }) .catch(() => { // Server TTS unavailable for this request — use the browser voice instead, // unless a newer tap has already superseded this one. if (seq !== requestSeq) return speakWebSpeech(text, lang) }) }