"use client"; /** * Text-to-speech for study cards, on the browser's own Web Speech API. * * No dependency and no audio leaves the app ourselves: the voice, the engine * and any cloud fallback belong to the operating system/browser. The module * keeps one utterance at a time app-wide (two cards reading over each other is * never what anyone wants) and notifies subscribers so the button that started * it can flip to "stop". */ const listeners = new Set<() => void>(); let speakingId: string | null = null; function notify(): void { listeners.forEach((listener) => listener()); } export function speechAvailable(): boolean { return typeof window !== "undefined" && "speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined"; } /** Language names ("Czech") and codes ("cs") → a BCP-47 tag for the voice. */ const LOCALES: Record = { english: "en-US", czech: "cs-CZ", slovak: "sk-SK", polish: "pl-PL", german: "de-DE", french: "fr-FR", spanish: "es-ES", italian: "it-IT", portuguese: "pt-PT", dutch: "nl-NL", swedish: "sv-SE", norwegian: "nb-NO", danish: "da-DK", finnish: "fi-FI", hungarian: "hu-HU", romanian: "ro-RO", turkish: "tr-TR", croatian: "hr-HR", slovenian: "sl-SI", serbian: "sr-RS", russian: "ru-RU", ukrainian: "uk-UA", bulgarian: "bg-BG", belarusian: "be-BY", macedonian: "mk-MK", greek: "el-GR", chinese: "zh-CN", mandarin: "zh-CN", japanese: "ja-JP", korean: "ko-KR", arabic: "ar-SA", persian: "fa-IR", farsi: "fa-IR", urdu: "ur-PK", hebrew: "he-IL", hindi: "hi-IN", marathi: "mr-IN", nepali: "ne-NP", thai: "th-TH", vietnamese: "vi-VN", }; /** * A BCP-47 tag for the deck's language, falling back to the interface locale * (so an untagged deck reads with the voice the user already hears the app in). */ export function speechLocale(language: string | undefined, fallback: string): string { const key = (language ?? "").trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, ""); if (key && LOCALES[key]) return LOCALES[key]; if (key.length === 2) return language!.trim(); return fallback === "cs" ? "cs-CZ" : "en-US"; } /** The installed voice closest to `locale` (exact match, then language). */ function pickVoice(locale: string): SpeechSynthesisVoice | null { try { const voices = window.speechSynthesis.getVoices(); if (voices.length === 0) return null; const wanted = locale.toLowerCase(); const prefix = wanted.split("-")[0]; return ( voices.find((voice) => voice.lang.toLowerCase() === wanted) ?? voices.find((voice) => voice.lang.toLowerCase().replace("_", "-") === wanted) ?? voices.find((voice) => voice.lang.toLowerCase().startsWith(`${prefix}-`) || voice.lang.toLowerCase() === prefix) ?? null ); } catch { return null; } } /** * Reads `text` aloud under `id` (usually the button's own React id, so the * button that started it is the one that shows "stop"). */ export function speak(id: string, text: string, locale: string): void { if (!speechAvailable()) return; const clean = text.replace(/…/g, ", ").replace(/_{2,}/g, "… ").trim(); if (!clean) return; try { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(clean); utterance.lang = locale; utterance.rate = 0.95; const voice = pickVoice(locale); if (voice) utterance.voice = voice; const done = () => { if (speakingId === id) { speakingId = null; notify(); } }; utterance.onend = done; utterance.onerror = done; speakingId = id; notify(); window.speechSynthesis.speak(utterance); } catch { speakingId = null; notify(); } } export function stopSpeaking(): void { if (!speechAvailable()) return; try { window.speechSynthesis.cancel(); } catch { /* already gone */ } if (speakingId !== null) { speakingId = null; notify(); } } /** True while `id`'s utterance is the one playing. */ export function isSpeaking(id: string): boolean { return speakingId === id; } export function subscribeSpeech(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); }