Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 43s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useRef, useState } from 'react'
|
|
|
|
interface AudioButtonProps {
|
|
text: string
|
|
lang: string
|
|
isDark: boolean
|
|
size?: 'sm' | 'md' | 'lg'
|
|
}
|
|
|
|
/**
|
|
* AudioButton — plays TTS audio for a word or phrase.
|
|
*
|
|
* Priority: Piper TTS (Thorsten DE / Lessac EN) via backend API.
|
|
* Fallback: Browser Web Speech API if Piper is unavailable.
|
|
*/
|
|
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
|
|
const [isSpeaking, setIsSpeaking] = useState(false)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
|
|
const speak = useCallback(async () => {
|
|
// Stop if already playing
|
|
if (isSpeaking) {
|
|
audioRef.current?.pause()
|
|
window.speechSynthesis?.cancel()
|
|
setIsSpeaking(false)
|
|
return
|
|
}
|
|
|
|
setIsSpeaking(true)
|
|
|
|
// Try Piper TTS via backend API first
|
|
try {
|
|
const url = `/api/vocabulary/tts?text=${encodeURIComponent(text)}&lang=${lang}`
|
|
const resp = await fetch(url)
|
|
if (resp.ok && resp.headers.get('content-type')?.startsWith('audio')) {
|
|
const blob = await resp.blob()
|
|
const audioUrl = URL.createObjectURL(blob)
|
|
const audio = new Audio(audioUrl)
|
|
audioRef.current = audio
|
|
audio.onended = () => { setIsSpeaking(false); URL.revokeObjectURL(audioUrl) }
|
|
audio.onerror = () => { setIsSpeaking(false); URL.revokeObjectURL(audioUrl) }
|
|
await audio.play()
|
|
return
|
|
}
|
|
} catch {
|
|
// Piper unavailable — fall through to Web Speech API
|
|
}
|
|
|
|
// Fallback: Browser Web Speech API
|
|
if ('speechSynthesis' in window) {
|
|
const utterance = new SpeechSynthesisUtterance(text)
|
|
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
|
utterance.rate = 0.9
|
|
const voices = window.speechSynthesis.getVoices()
|
|
const preferred = voices.find((v) =>
|
|
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
|
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
|
if (preferred) utterance.voice = preferred
|
|
utterance.onend = () => setIsSpeaking(false)
|
|
utterance.onerror = () => setIsSpeaking(false)
|
|
window.speechSynthesis.speak(utterance)
|
|
} else {
|
|
setIsSpeaking(false)
|
|
}
|
|
}, [text, lang, isSpeaking])
|
|
|
|
const sizeClasses = { sm: 'w-7 h-7', md: 'w-9 h-9', lg: 'w-11 h-11' }
|
|
const iconSizes = { sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-5 h-5' }
|
|
|
|
return (
|
|
<button
|
|
onClick={speak}
|
|
className={`${sizeClasses[size]} rounded-full flex items-center justify-center transition-all ${
|
|
isSpeaking
|
|
? 'bg-blue-500 text-white animate-pulse'
|
|
: isDark
|
|
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
|
|
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700'
|
|
}`}
|
|
title={isSpeaking ? 'Stop' : `${lang === 'de' ? 'Deutsch' : 'Englisch'} vorlesen`}
|
|
>
|
|
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
{isSpeaking ? (
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM10 9v6m4-6v6" />
|
|
) : (
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)
|
|
}
|