fix(presenter): unlock audio playback via AudioContext on user gesture

Browser autoplay policy blocks audio.play() outside user gesture.
Use AudioContext to unlock audio immediately in click handler.
Add console logging for TTS debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-20 12:38:16 +01:00
parent ddabda6f05
commit aece5f7414

View File

@@ -60,11 +60,30 @@ export function usePresenterMode({
const stateRef = useRef<PresenterState>('idle') const stateRef = useRef<PresenterState>('idle')
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
const audioUnlockedRef = useRef(false)
// Refs for recursive functions to avoid circular useCallback dependencies // Refs for recursive functions to avoid circular useCallback dependencies
const advanceRef = useRef<() => void>(() => {}) const advanceRef = useRef<() => void>(() => {})
const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {}) const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {})
// Unlock browser audio playback — must be called from a user gesture (click)
const unlockAudio = useCallback(() => {
if (audioUnlockedRef.current) return
try {
// Create and play a silent audio to unlock the Audio API
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
const buffer = ctx.createBuffer(1, 1, 22050)
const source = ctx.createBufferSource()
source.buffer = buffer
source.connect(ctx.destination)
source.start(0)
audioUnlockedRef.current = true
console.log('Audio playback unlocked')
} catch (e) {
console.warn('Audio unlock failed:', e)
}
}, [])
// Check TTS service availability on mount // Check TTS service availability on mount
useEffect(() => { useEffect(() => {
fetch('/api/presenter/tts', { fetch('/api/presenter/tts', {
@@ -147,6 +166,7 @@ export function usePresenterMode({
let blobUrl = audioCache.get(key) let blobUrl = audioCache.get(key)
if (!blobUrl) { if (!blobUrl) {
console.log('[TTS] Fetching audio for:', text.slice(0, 50) + '...')
const res = await fetch('/api/presenter/tts', { const res = await fetch('/api/presenter/tts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -157,8 +177,11 @@ export function usePresenterMode({
if (!res.ok) throw new Error(`TTS error: ${res.status}`) if (!res.ok) throw new Error(`TTS error: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
console.log('[TTS] Audio received:', blob.size, 'bytes')
blobUrl = URL.createObjectURL(blob) blobUrl = URL.createObjectURL(blob)
audioCache.set(key, blobUrl) audioCache.set(key, blobUrl)
} else {
console.log('[TTS] Cache hit for:', text.slice(0, 50) + '...')
} }
if (controller.signal.aborted) return if (controller.signal.aborted) return
@@ -167,6 +190,7 @@ export function usePresenterMode({
audioRef.current = audio audioRef.current = audio
audio.onended = () => { audio.onended = () => {
console.log('[TTS] Audio playback ended')
setIsSpeaking(false) setIsSpeaking(false)
audioRef.current = null audioRef.current = null
if (pauseAfter > 0) { if (pauseAfter > 0) {
@@ -176,22 +200,21 @@ export function usePresenterMode({
} }
} }
audio.onerror = () => { audio.onerror = (e) => {
console.warn('Audio playback error') console.warn('[TTS] Audio playback error:', e)
setIsSpeaking(false) setIsSpeaking(false)
audioRef.current = null audioRef.current = null
// Fallback to timer
const wordCount = text.split(/\s+/).length const wordCount = text.split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000) const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(onDone, readingTime + pauseAfter) timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
} }
await audio.play() await audio.play()
console.log('[TTS] Audio playing')
} catch (err: any) { } catch (err: any) {
if (err.name === 'AbortError') return if (err.name === 'AbortError') return
console.warn('TTS fetch error:', err) console.warn('[TTS] Error:', err.name, err.message)
setIsSpeaking(false) setIsSpeaking(false)
// Fallback to timer
const wordCount = text.split(/\s+/).length const wordCount = text.split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000) const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(onDone, readingTime + pauseAfter) timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
@@ -281,6 +304,9 @@ export function usePresenterMode({
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech]) }, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
const start = useCallback(() => { const start = useCallback(() => {
// Unlock audio playback immediately in user gesture context
unlockAudio()
clearTimer() clearTimer()
cancelSpeech() cancelSpeech()
setState('presenting') setState('presenting')
@@ -292,15 +318,12 @@ export function usePresenterMode({
const para = showParagraph(slideIdx, 0) const para = showParagraph(slideIdx, 0)
if (para) { if (para) {
const text = language === 'de' ? para.text_de : para.text_en const text = language === 'de' ? para.text_de : para.text_en
// Small delay so state is set before speaking speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
setTimeout(() => {
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}, 100)
} }
} else { } else {
timerRef.current = setTimeout(() => advanceRef.current(), 1000) timerRef.current = setTimeout(() => advanceRef.current(), 1000)
} }
}, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph]) }, [unlockAudio, clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
const stop = useCallback(() => { const stop = useCallback(() => {
clearTimer() clearTimer()
@@ -349,12 +372,13 @@ export function usePresenterMode({
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph]) }, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
const toggle = useCallback(() => { const toggle = useCallback(() => {
unlockAudio()
if (stateRef.current === 'idle') { if (stateRef.current === 'idle') {
start() start()
} else { } else {
stop() stop()
} }
}, [start, stop]) }, [unlockAudio, start, stop])
// Calculate overall progress // Calculate overall progress
const progress = (() => { const progress = (() => {