"use client"; /** * Choosing a voice for the podcast player. * * The Web Speech API ships no voices of its own: the list comes from the OS * and the browser, and it swings wildly between platforms — from espeak's * robot on a bare Linux box to Google's network voices, Microsoft's neural * ones and macOS's Alex. This module ranks what is installed, prefers a male * voice (a podcast host, per the listener's wish) among the good ones, and * remembers the listener's own pick per language. */ const STORAGE_KEY = "freezmo.podcast-voice.v1"; /** Names that usually mean "a human actually recorded this". */ const QUALITY_HINTS = ["natural", "neural", "online", "premium", "enhanced", "siri", "google", "microsoft"]; /** Engines that always sound like a 1980s answering machine. */ const ROBOTIC_HINTS = ["espeak", "sam", "pico", "festival", "flite", "compact", "robot"]; /** * Male first names that appear in speech-voice catalogues. Not exhaustive — * it only has to beat "pick the first voice for the language". */ const MALE_NAMES = [ "male", // "Google UK English Male" "alex", "daniel", "fred", "thomas", "oliver", "david", "mark", "james", "ryan", "guy", "george", "pavel", "jakub", "marek", "ondrej", "vojtech", "jirka", "petr", "tomas", "martin", "lukas", "jan", "antonin", "josef", "jorge", "diego", "carlos", "juan", "pablo", "luca", "matteo", "marco", "pierre", "henri", "klaus", "hans", "stefan", "felix", "magnus", "erik", "yuri", "dmitri", "ivan", "xander", "zhihao", "takumi", "haruto", "minho", "ji-hoon", "wei", "liang", "mikhail", ]; const FEMALE_NAMES = [ "female", // "Google UK English Female" "samantha", "karen", "moira", "tessa", "victoria", "allison", "ava", "susan", "zira", "siri", "zuzana", "iveta", "eliska", "tana", "katerina", "monika", "lucie", "pavla", "anna", "maria", "laura", "julia", "carmen", "paulina", "zofia", "elsa", "katja", "ingrid", "freya", "yelena", "svetlana", "olga", "yuki", "sakura", "mei", "ling", "xiaoxiao", ]; type StoredVoices = Record; let stored: StoredVoices | null = null; function loadStored(): StoredVoices { if (stored) return stored; stored = {}; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) { const parsed: unknown = JSON.parse(raw); if (parsed && typeof parsed === "object") { for (const [locale, uri] of Object.entries(parsed as Record)) { if (typeof uri === "string") stored[locale] = uri; } } } } catch { /* private mode */ } return stored; } const languageOf = (voice: SpeechSynthesisVoice) => voice.lang.toLowerCase().replace("_", "-"); /** Voices usable for `locale` (exact region first, then the language). */ export function voicesFor(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice[] { const all = voices ?? platformVoices(); if (all.length === 0) return []; const wanted = locale.toLowerCase().replace("_", "-"); const prefix = wanted.split("-")[0]; const matching = all.filter((voice) => { const lang = languageOf(voice); return lang === wanted || lang.startsWith(`${prefix}-`) || lang === prefix; }); /* Some platforms tag voices oddly (e.g. "en-GB" for a US voice): never show * an empty list when voices exist at all. */ return matching.length > 0 ? matching : all; } /** The platform's voice list (empty offline/unsupported). */ export function platformVoices(): SpeechSynthesisVoice[] { try { if (typeof window === "undefined" || !("speechSynthesis" in window)) return []; return window.speechSynthesis.getVoices(); } catch { return []; } } /** A larger score wins. `male` tilts the ranking towards male names. */ export function scoreVoice(voice: SpeechSynthesisVoice, locale: string, male: boolean): number { const name = voice.name.toLowerCase(); const lang = languageOf(voice); const wanted = locale.toLowerCase().replace("_", "-"); let score = 0; if (lang === wanted) score += 6; else if (lang.split("-")[0] === wanted.split("-")[0]) score += 3; if (QUALITY_HINTS.some((hint) => name.includes(hint))) score += 10; if (ROBOTIC_HINTS.some((hint) => name.includes(hint))) score -= 12; if (MALE_NAMES.some((entry) => name.includes(entry))) score += male ? 8 : 1; if (FEMALE_NAMES.some((entry) => name.includes(entry))) score += male ? 1 : 4; /* Network voices are usually the neural ones; local ones are the fallback. */ if (!voice.localService) score += 3; if (voice.default) score += 1; return score; } /** The best voice for the episode: the listener's stored pick, else the best * male, else the best of whatever there is. */ export function bestVoice(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice | null { const list = voicesFor(locale, voices); if (list.length === 0) return null; const chosen = getStoredVoice(locale); if (chosen) { const match = list.find((voice) => voice.voiceURI === chosen); if (match) return match; } const ranked = [...list].sort((a, b) => scoreVoice(b, locale, true) - scoreVoice(a, locale, true)); return ranked[0] ?? null; } export function getStoredVoice(locale: string): string | null { return loadStored()[locale.toLowerCase().replace("_", "-")] ?? null; } export function setStoredVoice(locale: string, voiceURI: string): void { const key = locale.toLowerCase().replace("_", "-"); const next = { ...loadStored(), [key]: voiceURI }; stored = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* private mode */ } } /** True when nothing but the platform's robotic voices exist for `locale`. */ export function onlyRoboticVoices(locale: string, voices?: SpeechSynthesisVoice[]): boolean { const list = voicesFor(locale, voices); if (list.length === 0) return false; return list.every((voice) => ROBOTIC_HINTS.some((hint) => voice.name.toLowerCase().includes(hint))); } export const MALE_VOICE_HINTS = MALE_NAMES; /** * iPhone/iPad (all iOS browsers are WebKit): Safari hands web pages a single * voice per language — the system's preferred one — no matter how many are * installed, so the picker needs to say where to get a better one. */ export function isAppleMobile(): boolean { if (typeof navigator === "undefined") return false; const ua = navigator.userAgent; if (/iPhone|iPod/.test(ua)) return true; /* iPadOS 13+ reports as a Mac; the touch points give it away. */ return /iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); }