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.
244 lines
6.6 KiB
TypeScript
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);
|
|
}
|