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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user