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:
parent
75423579b7
commit
67bc67f80b
351
apps/web/src/lib/ui-tts.ts
Normal file
351
apps/web/src/lib/ui-tts.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user