Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
121 lines
5.1 KiB
TypeScript
121 lines
5.1 KiB
TypeScript
// 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)
|
|
})
|
|
}
|