freezmo/lib/podcast-voices.ts
Martin Dvorak 9882d775e7 Podcast voices: an iPhone note on where the other voices live
Safari hands web pages one voice per language, whatever the iPhone has
installed, so the picker cannot fix that on iOS. It now carries the way out
instead: Accessibility -> Spoken Content -> Voices -> your language, download
an Enhanced/Premium voice and make it preferred. The speaker button wears an
amber dot while the note is waiting, and the voice-less banner appends the
same guidance. Detection covers iPadOS too (maxTouchPoints, since it reports
as a Mac).

The suite fakes an iPhone user agent, a single Daniel voice and pins the dot
and the note's actual steps (37 checks). Version 0.7.5, SW v97.
2026-10-16 13:51:12 +00:00

244 lines
6.6 KiB
TypeScript

"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<string, string>;
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<string, unknown>)) {
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);
}