diff --git a/apps/web/src/lib/ui-tts.ts b/apps/web/src/lib/ui-tts.ts new file mode 100644 index 0000000..886c043 --- /dev/null +++ b/apps/web/src/lib/ui-tts.ts @@ -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 | 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 { + if (this.voicePromise) return + + this.voicePromise = new Promise((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 { + 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 { + 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(initialText ?? null) + const [state, setState] = useState('idle') + const speakRef = useRef(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', + } +}