feat(presenter): add browser TTS (Web Speech API) + fix German umlauts

- Integrate Web Speech API into usePresenterMode for text-to-speech
- Speech-driven paragraph advancement (falls back to timer if TTS unavailable)
- TTS toggle button (Volume2/VolumeX) in PresenterOverlay
- Chrome keepAlive workaround for long speeches
- Voice selection: prefers premium/neural voices, falls back to any matching lang
- Fix all German umlauts across presenter-script, presenter-faq, i18n, route.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-20 12:11:12 +01:00
parent 3a2567b44d
commit bcbceba31c
9 changed files with 370 additions and 218 deletions

View File

@@ -11,6 +11,8 @@ interface UsePresenterModeConfig {
currentSlide: number
totalSlides: number
language: Language
speechRate?: number // 0.52.0, default 1.0
ttsEnabled?: boolean // default true
}
interface UsePresenterModeReturn {
@@ -24,6 +26,10 @@ interface UsePresenterModeReturn {
toggle: () => void
displayText: string
progress: number
isSpeaking: boolean
ttsAvailable: boolean
ttsEnabled: boolean
setTtsEnabled: (enabled: boolean) => void
}
export function usePresenterMode({
@@ -31,23 +37,64 @@ export function usePresenterMode({
currentSlide,
totalSlides,
language,
speechRate = 1.0,
ttsEnabled: initialTtsEnabled = true,
}: UsePresenterModeConfig): UsePresenterModeReturn {
const [state, setState] = useState<PresenterState>('idle')
const [currentParagraph, setCurrentParagraph] = useState(0)
const [displayText, setDisplayText] = useState('')
const [isSpeaking, setIsSpeaking] = useState(false)
const [ttsEnabled, setTtsEnabled] = useState(initialTtsEnabled)
const [ttsAvailable, setTtsAvailable] = useState(false)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const slideIndexRef = useRef(currentSlide)
const paragraphIndexRef = useRef(0)
const stateRef = useRef<PresenterState>('idle')
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
const voicesRef = useRef<SpeechSynthesisVoice[]>([])
// Refs for recursive functions to avoid circular useCallback dependencies
const advanceRef = useRef<() => void>(() => {})
const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {})
// Initialize Web Speech API voices
useEffect(() => {
if (typeof window === 'undefined' || !window.speechSynthesis) return
setTtsAvailable(true)
const loadVoices = () => {
voicesRef.current = window.speechSynthesis.getVoices()
}
loadVoices()
window.speechSynthesis.addEventListener('voiceschanged', loadVoices)
return () => {
window.speechSynthesis.removeEventListener('voiceschanged', loadVoices)
}
}, [])
const getVoice = useCallback((lang: Language): SpeechSynthesisVoice | null => {
const voices = voicesRef.current
if (!voices.length) return null
const langCode = lang === 'de' ? 'de' : 'en'
// Prefer high-quality voices
const premium = voices.find(v =>
v.lang.startsWith(langCode) && /premium|enhanced|neural|google|microsoft/i.test(v.name)
)
if (premium) return premium
return voices.find(v => v.lang.startsWith(langCode)) || null
}, [])
const cancelSpeech = useCallback(() => {
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel()
}
utteranceRef.current = null
setIsSpeaking(false)
}, [])
// Keep refs in sync
useEffect(() => {
slideIndexRef.current = currentSlide
}, [currentSlide])
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => { slideIndexRef.current = currentSlide }, [currentSlide])
useEffect(() => { stateRef.current = state }, [state])
const clearTimer = useCallback(() => {
if (timerRef.current) {
@@ -73,81 +120,133 @@ export function usePresenterMode({
return para
}, [language, getScriptForIndex])
const advancePresentation = useCallback(() => {
if (stateRef.current !== 'presenting') return
// Update speakAndAdvance ref whenever dependencies change
useEffect(() => {
speakAndAdvanceRef.current = (text: string, pauseAfter: number, onDone: () => void) => {
const canSpeak = ttsAvailable && ttsEnabled && typeof window !== 'undefined'
const slideIdx = slideIndexRef.current
const script = getScriptForIndex(slideIdx)
if (canSpeak) {
// Chrome bug: speechSynthesis can get stuck
window.speechSynthesis.cancel()
if (!script) {
// No script for this slide, advance to next
if (slideIdx < totalSlides - 1) {
goToSlide(slideIdx + 1)
paragraphIndexRef.current = 0
// Schedule next after slide transition
timerRef.current = setTimeout(() => advancePresentation(), 2000)
const utterance = new SpeechSynthesisUtterance(text)
const voice = getVoice(language)
if (voice) utterance.voice = voice
utterance.lang = language === 'de' ? 'de-DE' : 'en-US'
utterance.rate = speechRate
utterance.pitch = 1.0
const handleEnd = () => {
setIsSpeaking(false)
utteranceRef.current = null
if (pauseAfter > 0) {
timerRef.current = setTimeout(onDone, pauseAfter)
} else {
onDone()
}
}
utterance.onstart = () => setIsSpeaking(true)
utterance.onend = handleEnd
utterance.onerror = (e) => {
if (e.error !== 'canceled') console.warn('TTS error:', e.error)
setIsSpeaking(false)
utteranceRef.current = null
handleEnd()
}
utteranceRef.current = utterance
window.speechSynthesis.speak(utterance)
} else {
setState('idle')
setDisplayText('')
}
return
}
const nextPara = paragraphIndexRef.current + 1
if (nextPara < script.paragraphs.length) {
// Show next paragraph
const para = showParagraph(slideIdx, nextPara)
if (para) {
// Calculate display time: ~150ms per word + pause
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
// No TTS — use word-count-based timer
const wordCount = text.split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
}
} else {
// All paragraphs done for this slide
// Show transition hint briefly
if (script.transition_hint_de || script.transition_hint_en) {
}
}, [ttsAvailable, ttsEnabled, language, speechRate, getVoice])
// Update advancePresentation ref whenever dependencies change
useEffect(() => {
advanceRef.current = () => {
if (stateRef.current !== 'presenting') return
const slideIdx = slideIndexRef.current
const script = getScriptForIndex(slideIdx)
if (!script) {
if (slideIdx < totalSlides - 1) {
goToSlide(slideIdx + 1)
paragraphIndexRef.current = 0
timerRef.current = setTimeout(() => advanceRef.current(), 2000)
} else {
cancelSpeech()
setState('idle')
setDisplayText('')
}
return
}
const nextPara = paragraphIndexRef.current + 1
if (nextPara < script.paragraphs.length) {
const para = showParagraph(slideIdx, nextPara)
if (para) {
const text = language === 'de' ? para.text_de : para.text_en
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}
} else {
// All paragraphs done — transition hint then next slide
const hint = language === 'de'
? (script.transition_hint_de || '')
: (script.transition_hint_en || '')
setDisplayText(hint)
}
// Move to next slide
if (slideIdx < totalSlides - 1) {
timerRef.current = setTimeout(() => {
if (stateRef.current !== 'presenting') return
goToSlide(slideIdx + 1)
paragraphIndexRef.current = -1 // Will be incremented to 0
const goNext = () => {
if (slideIdx < totalSlides - 1) {
timerRef.current = setTimeout(() => {
if (stateRef.current !== 'presenting') return
goToSlide(slideIdx + 1)
paragraphIndexRef.current = -1
timerRef.current = setTimeout(() => {
timerRef.current = setTimeout(() => {
if (stateRef.current !== 'presenting') return
const nextScript = getScriptForIndex(slideIdx + 1)
if (nextScript && nextScript.paragraphs.length > 0) {
const para = showParagraph(slideIdx + 1, 0)
if (para) {
const text = language === 'de' ? para.text_de : para.text_en
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}
} else {
advanceRef.current()
}
}, 1500)
}, 1000)
} else {
timerRef.current = setTimeout(() => {
cancelSpeech()
setState('idle')
setDisplayText('')
}, 3000)
}
}
if (hint) {
setDisplayText(hint)
speakAndAdvanceRef.current(hint, 0, () => {
if (stateRef.current !== 'presenting') return
const nextScript = getScriptForIndex(slideIdx + 1)
if (nextScript && nextScript.paragraphs.length > 0) {
const para = showParagraph(slideIdx + 1, 0)
if (para) {
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
}
} else {
advancePresentation()
}
}, 1500)
}, 2000)
} else {
// Last slide — done
timerRef.current = setTimeout(() => {
setState('idle')
setDisplayText('')
}, 3000)
goNext()
})
} else {
goNext()
}
}
}
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph])
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
const start = useCallback(() => {
clearTimer()
cancelSpeech()
setState('presenting')
const slideIdx = slideIndexRef.current
@@ -156,41 +255,43 @@ export function usePresenterMode({
if (script && script.paragraphs.length > 0) {
const para = showParagraph(slideIdx, 0)
if (para) {
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
const text = language === 'de' ? para.text_de : para.text_en
// Small delay so state is set before speaking
setTimeout(() => {
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}, 100)
}
} else {
// No script, advance immediately
timerRef.current = setTimeout(() => advancePresentation(), 1000)
timerRef.current = setTimeout(() => advanceRef.current(), 1000)
}
}, [clearTimer, language, getScriptForIndex, showParagraph, advancePresentation])
}, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
const stop = useCallback(() => {
clearTimer()
cancelSpeech()
setState('idle')
setDisplayText('')
setCurrentParagraph(0)
paragraphIndexRef.current = 0
}, [clearTimer])
}, [clearTimer, cancelSpeech])
const pause = useCallback(() => {
clearTimer()
cancelSpeech()
setState('paused')
}, [clearTimer])
}, [clearTimer, cancelSpeech])
const resume = useCallback(() => {
setState('resuming')
// Brief pause before continuing
timerRef.current = setTimeout(() => {
setState('presenting')
// Continue from where we left off
advancePresentation()
advanceRef.current()
}, 2000)
}, [advancePresentation])
}, [])
const skipSlide = useCallback(() => {
clearTimer()
cancelSpeech()
const nextIdx = slideIndexRef.current + 1
if (nextIdx < totalSlides) {
goToSlide(nextIdx)
@@ -202,15 +303,14 @@ export function usePresenterMode({
if (script && script.paragraphs.length > 0) {
const para = showParagraph(nextIdx, 0)
if (para) {
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
const text = language === 'de' ? para.text_de : para.text_en
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}
}
}, 1500)
}
}
}, [clearTimer, totalSlides, goToSlide, language, getScriptForIndex, showParagraph, advancePresentation])
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
const toggle = useCallback(() => {
if (stateRef.current === 'idle') {
@@ -236,8 +336,23 @@ export function usePresenterMode({
// Cleanup on unmount
useEffect(() => {
return () => clearTimer()
}, [clearTimer])
return () => {
clearTimer()
cancelSpeech()
}
}, [clearTimer, cancelSpeech])
// Chrome workaround: speechSynthesis pauses after ~15s without interaction
useEffect(() => {
if (state !== 'presenting' || !ttsEnabled || !ttsAvailable) return
const keepAlive = setInterval(() => {
if (typeof window !== 'undefined' && window.speechSynthesis?.speaking) {
window.speechSynthesis.pause()
window.speechSynthesis.resume()
}
}, 10000)
return () => clearInterval(keepAlive)
}, [state, ttsEnabled, ttsAvailable])
return {
state,
@@ -250,5 +365,9 @@ export function usePresenterMode({
toggle,
displayText,
progress,
isSpeaking,
ttsAvailable,
ttsEnabled,
setTtsEnabled,
}
}