// 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 function stopCurrent(): void { 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) } // speak reads `text` aloud, cancelling anything already in flight so rapid taps // don't queue up. `lang` defaults to US English (the language she's learning); // pass a zh locale to hear a Chinese passage. 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 = 'en-US'): void { if (!text.trim()) return stopCurrent() 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) }) }