Files
petal/web/src/audio/speech.ts
prosolis 5120b1e5a2 Read-aloud: natural neural voice via local Piper TTS
Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.

- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
  ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
  loads one voice, so language routes to its own endpoint:{voice}. Unknown
  language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
  for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
  TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
  TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
  any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.

English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:34:52 -07:00

105 lines
4.2 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
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)
})
}