"The scrolling works kinda weirdly" + "maybe a less robotic voice, preferably male". Scroll: the transcript now follows by LINE, not by word - words on the same line never move the page (that was the jitter), a line only scrolls once it leaves a comfortable band, small corrections are instant rather than animated, and a wheel/touch/drag on the transcript pauses the following for four seconds instead of fighting the reader. Chapter jumps and word taps re-centre at once. Voice: lib/podcast-voices ranks what the platform installed (natural / neural / online names up, espeak / SAM / compact down, a male name preferred) and the player picks the best one automatically; a new picker in the chrome lists everything in the episode's language, applies it live and remembers the choice per language. A note points at the system settings when only robotic voices exist. The engine takes the chosen voice instead of guessing. podcast-test: 34 checks now (voice ranking, filtered picker, persisted pick, same-line silence, line following, manual-scroll suspension). Version 0.7.4, SW v96.
231 lines
6 KiB
TypeScript
231 lines
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;
|