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