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>
This commit is contained in:
Benjamin Admin
2026-04-26 23:17:39 +02:00
parent 3cdab5a967
commit 0f0bbc3dc0
3 changed files with 82 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
interface AudioButtonProps {
text: string
@@ -9,47 +9,65 @@ interface AudioButtonProps {
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(() => {
if (!('speechSynthesis' in window)) return
const speak = useCallback(async () => {
// Stop if already playing
if (isSpeaking) {
window.speechSynthesis.cancel()
audioRef.current?.pause()
window.speechSynthesis?.cancel()
setIsSpeaking(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
utterance.rate = 0.9
utterance.pitch = 1.0
// Try to find a good voice
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)
setIsSpeaking(true)
window.speechSynthesis.speak(utterance)
// 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',
}
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