mirror of
https://github.com/dergigi/boris.git
synced 2026-01-16 21:34:21 +01:00
feat(tts): add Web Speech API hook
This commit is contained in:
124
src/hooks/useTextToSpeech.ts
Normal file
124
src/hooks/useTextToSpeech.ts
Normal 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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user