- New API route /api/presenter/tts proxies to compliance-tts-service - usePresenterMode now uses Audio element with Piper-generated MP3 - Client-side audio caching (text hash → blob URL) avoids re-synthesis - Graceful fallback to word-count timer if TTS service unavailable - Add TTS_SERVICE_URL env var to pitch-deck Docker config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
import { Language, SlideId } from '../types'
|
|
import { PresenterState } from '../presenter/types'
|
|
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
|
import { SLIDE_ORDER } from './useSlideNavigation'
|
|
|
|
interface UsePresenterModeConfig {
|
|
goToSlide: (index: number) => void
|
|
currentSlide: number
|
|
totalSlides: number
|
|
language: Language
|
|
ttsEnabled?: boolean // default true
|
|
}
|
|
|
|
interface UsePresenterModeReturn {
|
|
state: PresenterState
|
|
currentParagraph: number
|
|
start: () => void
|
|
stop: () => void
|
|
pause: () => void
|
|
resume: () => void
|
|
skipSlide: () => void
|
|
toggle: () => void
|
|
displayText: string
|
|
progress: number
|
|
isSpeaking: boolean
|
|
ttsAvailable: boolean
|
|
ttsEnabled: boolean
|
|
setTtsEnabled: (enabled: boolean) => void
|
|
}
|
|
|
|
// Client-side audio cache: text hash → blob URL
|
|
const audioCache = new Map<string, string>()
|
|
|
|
async function hashText(text: string): Promise<string> {
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(text)
|
|
const hash = await crypto.subtle.digest('SHA-256', data)
|
|
return Array.from(new Uint8Array(hash)).slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
export function usePresenterMode({
|
|
goToSlide,
|
|
currentSlide,
|
|
totalSlides,
|
|
language,
|
|
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 audioRef = useRef<HTMLAudioElement | null>(null)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
|
|
// Refs for recursive functions to avoid circular useCallback dependencies
|
|
const advanceRef = useRef<() => void>(() => {})
|
|
const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {})
|
|
|
|
// Check TTS service availability on mount
|
|
useEffect(() => {
|
|
fetch('/api/presenter/tts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: 'Test', language: 'de' }),
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
.then(res => {
|
|
setTtsAvailable(res.ok)
|
|
if (res.ok) console.log('Piper TTS available')
|
|
else console.warn('Piper TTS not available:', res.status)
|
|
})
|
|
.catch(() => {
|
|
setTtsAvailable(false)
|
|
console.warn('Piper TTS service not reachable')
|
|
})
|
|
}, [])
|
|
|
|
const cancelSpeech = useCallback(() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause()
|
|
audioRef.current.currentTime = 0
|
|
audioRef.current = null
|
|
}
|
|
if (abortRef.current) {
|
|
abortRef.current.abort()
|
|
abortRef.current = null
|
|
}
|
|
setIsSpeaking(false)
|
|
}, [])
|
|
|
|
// Keep refs in sync
|
|
useEffect(() => { slideIndexRef.current = currentSlide }, [currentSlide])
|
|
useEffect(() => { stateRef.current = state }, [state])
|
|
|
|
const clearTimer = useCallback(() => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current)
|
|
timerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const getScriptForIndex = useCallback((index: number) => {
|
|
const slideId = SLIDE_ORDER[index]
|
|
return PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
|
}, [])
|
|
|
|
const showParagraph = useCallback((slideIdx: number, paraIdx: number) => {
|
|
const script = getScriptForIndex(slideIdx)
|
|
if (!script || paraIdx >= script.paragraphs.length) return null
|
|
|
|
const para = script.paragraphs[paraIdx]
|
|
const text = language === 'de' ? para.text_de : para.text_en
|
|
setDisplayText(text)
|
|
setCurrentParagraph(paraIdx)
|
|
paragraphIndexRef.current = paraIdx
|
|
return para
|
|
}, [language, getScriptForIndex])
|
|
|
|
// Update speakAndAdvance ref whenever dependencies change
|
|
useEffect(() => {
|
|
speakAndAdvanceRef.current = (text: string, pauseAfter: number, onDone: () => void) => {
|
|
if (!ttsAvailable || !ttsEnabled) {
|
|
// No TTS — use word-count-based timer
|
|
const wordCount = text.split(/\s+/).length
|
|
const readingTime = Math.max(wordCount * 150, 2000)
|
|
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
|
return
|
|
}
|
|
|
|
// Piper TTS via API
|
|
setIsSpeaking(true)
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
const playAudio = async () => {
|
|
try {
|
|
const key = await hashText(text + language)
|
|
let blobUrl = audioCache.get(key)
|
|
|
|
if (!blobUrl) {
|
|
const res = await fetch('/api/presenter/tts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text, language }),
|
|
signal: controller.signal,
|
|
})
|
|
|
|
if (!res.ok) throw new Error(`TTS error: ${res.status}`)
|
|
|
|
const blob = await res.blob()
|
|
blobUrl = URL.createObjectURL(blob)
|
|
audioCache.set(key, blobUrl)
|
|
}
|
|
|
|
if (controller.signal.aborted) return
|
|
|
|
const audio = new Audio(blobUrl)
|
|
audioRef.current = audio
|
|
|
|
audio.onended = () => {
|
|
setIsSpeaking(false)
|
|
audioRef.current = null
|
|
if (pauseAfter > 0) {
|
|
timerRef.current = setTimeout(onDone, pauseAfter)
|
|
} else {
|
|
onDone()
|
|
}
|
|
}
|
|
|
|
audio.onerror = () => {
|
|
console.warn('Audio playback error')
|
|
setIsSpeaking(false)
|
|
audioRef.current = null
|
|
// Fallback to timer
|
|
const wordCount = text.split(/\s+/).length
|
|
const readingTime = Math.max(wordCount * 150, 2000)
|
|
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
|
}
|
|
|
|
await audio.play()
|
|
} catch (err: any) {
|
|
if (err.name === 'AbortError') return
|
|
console.warn('TTS fetch error:', err)
|
|
setIsSpeaking(false)
|
|
// Fallback to timer
|
|
const wordCount = text.split(/\s+/).length
|
|
const readingTime = Math.max(wordCount * 150, 2000)
|
|
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
|
}
|
|
}
|
|
|
|
playAudio()
|
|
}
|
|
}, [ttsAvailable, ttsEnabled, language])
|
|
|
|
// 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 || '')
|
|
|
|
const goNext = () => {
|
|
if (slideIdx < totalSlides - 1) {
|
|
timerRef.current = setTimeout(() => {
|
|
if (stateRef.current !== 'presenting') return
|
|
goToSlide(slideIdx + 1)
|
|
paragraphIndexRef.current = -1
|
|
|
|
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
|
|
goNext()
|
|
})
|
|
} else {
|
|
goNext()
|
|
}
|
|
}
|
|
}
|
|
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
|
|
|
|
const start = useCallback(() => {
|
|
clearTimer()
|
|
cancelSpeech()
|
|
setState('presenting')
|
|
|
|
const slideIdx = slideIndexRef.current
|
|
const script = getScriptForIndex(slideIdx)
|
|
|
|
if (script && script.paragraphs.length > 0) {
|
|
const para = showParagraph(slideIdx, 0)
|
|
if (para) {
|
|
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 {
|
|
timerRef.current = setTimeout(() => advanceRef.current(), 1000)
|
|
}
|
|
}, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
|
|
|
|
const stop = useCallback(() => {
|
|
clearTimer()
|
|
cancelSpeech()
|
|
setState('idle')
|
|
setDisplayText('')
|
|
setCurrentParagraph(0)
|
|
paragraphIndexRef.current = 0
|
|
}, [clearTimer, cancelSpeech])
|
|
|
|
const pause = useCallback(() => {
|
|
clearTimer()
|
|
cancelSpeech()
|
|
setState('paused')
|
|
}, [clearTimer, cancelSpeech])
|
|
|
|
const resume = useCallback(() => {
|
|
setState('resuming')
|
|
timerRef.current = setTimeout(() => {
|
|
setState('presenting')
|
|
advanceRef.current()
|
|
}, 2000)
|
|
}, [])
|
|
|
|
const skipSlide = useCallback(() => {
|
|
clearTimer()
|
|
cancelSpeech()
|
|
const nextIdx = slideIndexRef.current + 1
|
|
if (nextIdx < totalSlides) {
|
|
goToSlide(nextIdx)
|
|
paragraphIndexRef.current = -1
|
|
|
|
if (stateRef.current === 'presenting') {
|
|
timerRef.current = setTimeout(() => {
|
|
const script = getScriptForIndex(nextIdx)
|
|
if (script && script.paragraphs.length > 0) {
|
|
const para = showParagraph(nextIdx, 0)
|
|
if (para) {
|
|
const text = language === 'de' ? para.text_de : para.text_en
|
|
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
|
|
}
|
|
}
|
|
}, 1500)
|
|
}
|
|
}
|
|
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
|
|
|
|
const toggle = useCallback(() => {
|
|
if (stateRef.current === 'idle') {
|
|
start()
|
|
} else {
|
|
stop()
|
|
}
|
|
}, [start, stop])
|
|
|
|
// Calculate overall progress
|
|
const progress = (() => {
|
|
if (state === 'idle') return 0
|
|
const totalScripts = PRESENTER_SCRIPT.length
|
|
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide])
|
|
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
|
|
|
|
const script = PRESENTER_SCRIPT[currentScriptIdx]
|
|
const slideProgress = script.paragraphs.length > 0
|
|
? currentParagraph / script.paragraphs.length
|
|
: 0
|
|
return ((currentScriptIdx + slideProgress) / totalScripts) * 100
|
|
})()
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
clearTimer()
|
|
cancelSpeech()
|
|
}
|
|
}, [clearTimer, cancelSpeech])
|
|
|
|
return {
|
|
state,
|
|
currentParagraph,
|
|
start,
|
|
stop,
|
|
pause,
|
|
resume,
|
|
skipSlide,
|
|
toggle,
|
|
displayText,
|
|
progress,
|
|
isSpeaking,
|
|
ttsAvailable,
|
|
ttsEnabled,
|
|
setTtsEnabled,
|
|
}
|
|
}
|