feat(tts): add Web Speech API hook

This commit is contained in:
Gigi
2025-10-20 21:41:07 +02:00
parent 95f6949ab7
commit a51fbd25d7

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface UseTTSOptions {
defaultLang?: string
defaultRate?: number
defaultPitch?: number
defaultVolume?: number
}
export interface UseTTS {
supported: boolean
speaking: boolean
paused: boolean
voices: SpeechSynthesisVoice[]
voice: SpeechSynthesisVoice | null
rate: number
pitch: number
volume: number
setVoice: (v: SpeechSynthesisVoice | null) => void
setRate: (r: number) => void
setPitch: (p: number) => void
setVolume: (v: number) => void
speak: (text: string, langOverride?: string) => void
pause: () => void
resume: () => void
stop: () => void
}
export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined
const supported = !!synth
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [voice, setVoice] = useState<SpeechSynthesisVoice | null>(null)
const [speaking, setSpeaking] = useState(false)
const [paused, setPaused] = useState(false)
const [rate, setRate] = useState(options.defaultRate ?? 1)
const [pitch, setPitch] = useState(options.defaultPitch ?? 1)
const [volume, setVolume] = useState(options.defaultVolume ?? 1)
const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en')
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
// Load voices (async in many browsers)
useEffect(() => {
if (!supported) return
const load = () => {
const v = synth!.getVoices()
setVoices(v)
if (!voice && v.length) {
// pick best match by language first, then default
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null)
}
}
load()
// Safari/Chrome fire 'voiceschanged'
synth!.addEventListener?.('voiceschanged', load as EventListener)
return () => {
synth!.removeEventListener?.('voiceschanged', load as EventListener)
}
}, [supported, defaultLang, voice, synth])
const stop = useCallback(() => {
if (!supported) return
synth!.cancel()
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
}, [supported, synth])
const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return
// stopping any current speech first is safer for iOS
synth!.cancel()
const u = new SpeechSynthesisUtterance(text)
u.lang = langOverride || voice?.lang || defaultLang
if (voice) u.voice = voice
u.rate = rate
u.pitch = pitch
u.volume = volume
u.onstart = () => { setSpeaking(true); setPaused(false) }
u.onpause = () => setPaused(true)
u.onresume = () => setPaused(false)
u.onend = () => { setSpeaking(false); setPaused(false); utteranceRef.current = null }
u.onerror = () => { setSpeaking(false); setPaused(false); utteranceRef.current = null }
utteranceRef.current = u
synth!.speak(u)
}, [supported, synth, voice, rate, pitch, volume, defaultLang])
const pause = useCallback(() => {
if (!supported) return
if (synth!.speaking && !synth!.paused) {
synth!.pause()
setPaused(true)
}
}, [supported, synth])
const resume = useCallback(() => {
if (!supported) return
if (synth!.speaking && synth!.paused) {
synth!.resume()
setPaused(false)
}
}, [supported, synth])
// stop TTS when unmounting
useEffect(() => stop, [stop])
return useMemo(() => ({
supported,
speaking,
paused,
voices,
voice,
rate, setRate,
pitch, setPitch,
volume, setVolume,
setVoice,
speak, pause, resume, stop
}), [supported, speaking, paused, voices, voice, rate, pitch, volume, setVoice, speak, pause, resume, stop])
}