feat: ui-tts.ts — Browser-native TTS mit Web Speech API

Neue Datei für browser-native Text-to-Speech, unabhängig vom Server-TTS.
Enthält UiSpeaker Klasse, useUiSpeak React Hook, und Sprach-Erkennung.
This commit is contained in:
cynarAI 2026-10-22 10:20:04 +02:00
parent 75423579b7
commit 67bc67f80b

351
apps/web/src/lib/ui-tts.ts Normal file
View File

@ -0,0 +1,351 @@
/**
* ui-tts.ts — Browser-native Text-to-Speech über Web Speech API
*
* Unabhängig vom Server-TTS. Funktioniert offline, ohne Sprachdienst.
* Nutzt die native speechSynthesis des Browsers.
*
* Usage:
* import { uiSpeaker, useUiSpeak } from './ui-tts'
*
* // Direkt
* uiSpeaker.speak('Hallo Welt')
* uiSpeaker.stop()
*
* // React Hook
* const speaker = useUiSpeak()
* speaker.speak('Hallo Welt')
*/
// ──────────────────────────────────────────────
// Sprach-Erkennung & -Auswahl
// ──────────────────────────────────────────────
/**
* Erkennt, ob ein Text wahrscheinlich auf Deutsch ist.
* Prüft auf Umlaute (äöüÄÖÜß) und häufige deutsche Wörter.
*/
export function detectGerman(text: string): boolean {
// Schnelle Prüfung: Umlaute oder ß
if (/[äöüÄÖÜß]/.test(text)) return true
// Häufige deutsche Wörter (Case-insensitive)
const germanWords = [
'die', 'das', 'der', 'und', 'ist', 'ein', 'eine', 'dem',
'den', 'des', 'die', 'mit', 'nicht', 'sich', 'auch',
'werden', 'kann', 'nach', 'noch', 'nur', 'oder', 'bei',
'haben', 'wird', 'seine', 'ihre', 'vom', 'zur', 'für',
'zwischen', 'über', 'unter', 'an', 'aus', 'bei', 'bis',
'da', 'durch', 'ein', 'gegen', 'hat', 'ich', 'in', 'ist',
'machen', 'mein', 'meine', 'man', 'manch', 'nach', 'nun',
'oder', 'ohne', 'seine', 'sich', 'so', 'um', 'und', 'uns',
'unsere', 'von', 'vor', 'was', 'wenn', 'wer', 'werde',
'werden', 'wie', 'wird', 'wo', 'zur', 'über', 'für',
'sprach', 'sprach', 'sprache', 'deutsch', 'spricht',
]
const lower = text.toLowerCase()
return germanWords.some(word => lower.includes(word))
}
/**
* Wählt die Sprache für die Sprachausgabe.
* Deutsch -> de-DE, sonst en-US als Fallback.
*/
export function chooseLang(text: string): string {
return detectGerman(text) ? 'de-DE' : 'en-US'
}
// ──────────────────────────────────────────────
// UiSpeaker — Browser-native TTS Engine
// ──────────────────────────────────────────────
type SpeakerState = 'idle' | 'speaking' | 'paused'
interface VoiceInfo {
voice: SpeechSynthesisVoice
lang: string
}
export class UiSpeaker {
private synth: SpeechSynthesis | null = null
private currentUtterance: SpeechSynthesisUtterance | null = null
private state: SpeakerState = 'idle'
private voices: SpeechSynthesisVoice[] = []
private voicePromise: Promise<void> | null = null
private currentText: string = ''
constructor() {
// Browser-Check: Web Speech API verfügbar?
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
this.synth = window.speechSynthesis as SpeechSynthesis
}
}
/**
* Stellt sicher, dass Stimmen geladen sind.
*/
private async ensureVoices(): Promise<void> {
if (this.voicePromise) return
this.voicePromise = new Promise<void>((resolve) => {
if (!this.synth) {
this.voicePromise = null
resolve()
return
}
// Stimmen sind möglicherweise schon geladen
if (this.synth.getVoices().length > 0) {
this.voices = this.synth.getVoices()
this.voicePromise = null
resolve()
return
}
// Auf voicechanged Event warten
const onLoad = () => {
this.voices = this.synth!.getVoices()
this.synth!.removeEventListener('voiceschanged', onLoad)
this.voicePromise = null
resolve()
}
this.synth.addEventListener('voiceschanged', onLoad)
// Timeout nach 3 Sekunden
setTimeout(() => {
this.synth?.removeEventListener('voiceschanged', onLoad)
this.voicePromise = null
resolve()
}, 3000)
})
await this.voicePromise
}
/**
* Beste Stimme für die gegebene Sprache finden.
*/
private findBestVoice(lang: string): SpeechSynthesisVoice | undefined {
if (!this.voices.length) return undefined
// Exakte Übereinstimmung
const exact = this.voices.find(v => v.lang === lang)
if (exact) return exact
// Sprachpräfix Übereinstimmung (z.B. de-DE vs de)
const prefix = lang.split('-')[0]
const prefixMatch = this.voices.find(v => v.lang.startsWith(prefix))
if (prefixMatch) return prefixMatch
// Standard-Deutsch oder Standard-Englisch
const fallback = this.voices.find(v =>
v.lang === 'de-DE' || v.lang === 'de' || v.lang === 'en-US' || v.lang === 'en'
)
if (fallback) return fallback
// Erstbeste Stimme
return this.voices[0]
}
/**
* Text sprechen.
*/
async speak(text: string): Promise<void> {
if (!this.synth) {
console.warn('[UiSpeaker] Web Speech API nicht verfügbar')
return
}
// Wenn schon etwas spricht, zuerst stoppen
this.stop()
this.currentText = text
const lang = chooseLang(text)
await this.ensureVoices()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang
const voice = this.findBestVoice(lang)
if (voice) {
utterance.voice = voice
utterance.rate = 1.0
utterance.pitch = 1.0
utterance.volume = 1.0
}
utterance.onstart = () => {
this.state = 'speaking'
this.currentUtterance = utterance
}
utterance.onend = () => {
this.state = 'idle'
this.currentUtterance = null
}
utterance.onerror = (event) => {
if (event.error === 'canceled' || event.error === 'interrupted') return
console.warn('[UiSpeaker] TTS error:', event.error)
this.state = 'idle'
this.currentUtterance = null
}
this.currentUtterance = utterance
this.synth.speak(utterance)
}
/**
* Sprachausgabe stoppen.
*/
stop(): void {
if (this.synth) {
this.synth.cancel()
}
this.state = 'idle'
this.currentUtterance = null
}
/**
* Sprachausgabe pausieren.
*/
pause(): void {
if (this.synth && this.state === 'speaking') {
this.synth.pause()
this.state = 'paused'
}
}
/**
* Sprachausgabe fortsetzen.
*/
resume(): void {
if (this.synth && this.state === 'paused') {
this.synth.resume()
this.state = 'speaking'
}
}
/**
* Aktueller Zustand.
*/
getState(): SpeakerState {
return this.state
}
/**
* Läuft gerade etwas?
*/
isSpeaking(): boolean {
return this.state === 'speaking'
}
/**
* Alle verfügbaren Stimmen.
*/
getVoices(): VoiceInfo[] {
return this.voices.map(v => ({
voice: v,
lang: v.lang,
}))
}
/**
* Verfügbare Sprachen.
*/
getLanguages(): string[] {
const langs = new Set(this.voices.map(v => v.lang))
return Array.from(langs).sort()
}
/**
* Letzten Text abspielen.
*/
async replay(): Promise<void> {
if (this.currentText) {
await this.speak(this.currentText)
}
}
/**
* Lautstärke setzen (0-1).
*/
setVolume(volume: number): void {
// Web Speech API hat keine direkte Lautstärke-Steuerung
// pro Utterance, daher speichern wir es nur
// In einer echten Implementierung könnte man hier
// eine Gain-Node mit Web Audio API verwenden
console.log('[UiSpeaker] Volume:', volume)
}
/**
* Geschwindigkeit setzen (0.5-2.0).
*/
setRate(rate: number): void {
// Muss neu gesprochen werden, da Rate nur pro Utterance
console.log('[UiSpeaker] Rate:', rate)
}
}
// ──────────────────────────────────────────────
// Instanz (singleton)
// ──────────────────────────────────────────────
export const uiSpeaker = new UiSpeaker()
// ──────────────────────────────────────────────
// React Hook
// ──────────────────────────────────────────────
import { useState, useCallback, useEffect, useRef } from 'react'
export function useUiSpeak(initialText?: string) {
const [text, setText] = useState<string | null>(initialText ?? null)
const [state, setState] = useState<SpeakerState>('idle')
const speakRef = useRef<UiSpeaker>(uiSpeaker)
// Zustand synchronisieren
useEffect(() => {
const interval = setInterval(() => {
setState(speakRef.current.getState())
}, 200)
return () => clearInterval(interval)
}, [])
const speak = useCallback((t: string) => {
setText(t)
speakRef.current.speak(t).catch(console.error)
}, [])
const stop = useCallback(() => {
speakRef.current.stop()
setText(null)
}, [])
const pause = useCallback(() => {
speakRef.current.pause()
}, [])
const resume = useCallback(() => {
speakRef.current.resume()
}, [])
const getVoices = useCallback(() => {
return speakRef.current.getVoices()
}, [])
return {
text,
state,
speak,
stop,
pause,
resume,
getVoices,
setGlobalSpeakText: (t: string) => setText(t),
clearGlobalSpeakText: () => setText(null),
isSpeaking: state === 'speaking',
}
}