Podcast player: calmer line-following scroll, and a voice you can pick

"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.
This commit is contained in:
Martin Dvořák 2026-10-16 13:05:57 +00:00
commit 4ec986da12
8 changed files with 684 additions and 46 deletions

231
lib/podcast-voices.ts Normal file
View file

@ -0,0 +1,231 @@
"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;