// Discord-style live captions. Uses the browser's SpeechRecognition API to // transcribe the LOCAL user's mic, then broadcasts each interim/final result // to peers via the LiveKit DataChannel. Receivers store and display them. // // Privacy note: speech recognition runs in the browser. On Chromium-based // runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's // own engine, which today reaches Google's cloud — same trade-off as Discord. // We ship a hard off switch and require an explicit user toggle. const STORAGE_KEY = 'chatapp.liveCaptions.v1'; export interface LiveCaptionsSettings { enabled: boolean; /** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */ lang: string | null; } const DEFAULTS: LiveCaptionsSettings = { enabled: false, lang: null, }; type Listener = (s: LiveCaptionsSettings) => void; const listeners = new Set(); let cached: LiveCaptionsSettings | null = null; function read(): LiveCaptionsSettings { if (cached) return cached; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { cached = DEFAULTS; return cached; } const parsed = JSON.parse(raw) as Partial; cached = { enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled, lang: typeof parsed.lang === 'string' && parsed.lang.length > 0 ? parsed.lang : DEFAULTS.lang, }; return cached; } catch { cached = DEFAULTS; return cached; } } function write(s: LiveCaptionsSettings): void { cached = s; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch { /* quota / private mode */ } for (const l of listeners) l(s); } export function getLiveCaptionsSettings(): LiveCaptionsSettings { return read(); } export function updateLiveCaptionsSettings( patch: Partial, ): LiveCaptionsSettings { const next = { ...read(), ...patch }; write(next); return next; } export function subscribeLiveCaptionsSettings(listener: Listener): () => void { listeners.add(listener); return () => listeners.delete(listener); } // Browser feature-detect. Chromium ships under both names; Firefox lacks it // outright. Returns the constructor or null. type SpeechRecognitionCtor = new () => SpeechRecognitionLike; interface SpeechRecognitionLike extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; start: () => void; stop: () => void; abort: () => void; onresult: ((e: SpeechRecognitionEventLike) => void) | null; onerror: ((e: SpeechRecognitionErrorLike) => void) | null; onend: (() => void) | null; } interface SpeechRecognitionEventLike { resultIndex: number; results: ArrayLike<{ isFinal: boolean; [index: number]: { transcript: string }; length: number; }>; } interface SpeechRecognitionErrorLike { error: string; } export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor; }; return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } export function isLiveCaptionsSupported(): boolean { return getSpeechRecognitionCtor() !== null; } export type { SpeechRecognitionLike, SpeechRecognitionEventLike, SpeechRecognitionErrorLike, };