Files
breakpilot-lehrer/studio-v2/components/learn/AudioButton.tsx
Benjamin Admin 0f0bbc3dc0 Switch AudioButton to Piper TTS (Thorsten/Lessac voices)
AudioButton now tries Piper TTS via /api/vocabulary/tts endpoint
first, falls back to Browser Web Speech API if unavailable.

Backend: New GET /api/vocabulary/tts?text=...&lang=de endpoint.
audio_service.py: Fixed presigned URL flow for MinIO download.

This gives the same high-quality voice as the Investor Agent
in the pitch deck (Thorsten DE / Lessac EN, MIT license).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 23:17:39 +02:00

94 lines
3.4 KiB
TypeScript

'use client'
import React, { useCallback, useRef, useState } from 'react'
interface AudioButtonProps {
text: string
lang: 'en' | 'de'
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>
)
}