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

View file

@ -302,6 +302,11 @@ const en: Record<string, string> = {
"podcast.pause": "Pause the episode",
"podcast.restart": "Start from the beginning",
"podcast.speedAria": "Playback speed",
"podcast.voiceAria": "Voice",
"podcast.voiceAuto": "Automatic (best available)",
"podcast.voiceLocal": "installed",
"podcast.voiceNote": "Voices come from your browser and system — pick the one that sounds best to you.",
"podcast.voiceRobotic": "Only the basic system voices are installed. For a natural host voice, add one in your system settings (Windows: Speech; macOS: Spoken Content; Chrome ships the Google voices).",
"podcast.tapToJump": "Tap any word to jump there.",
"podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
"podcast.loadingEpisode": "Loading the episode",
@ -1369,6 +1374,11 @@ const cs: Record<string, string> = {
"podcast.pause": "Pozastavit epizodu",
"podcast.restart": "Začít od začátku",
"podcast.speedAria": "Rychlost přehrávání",
"podcast.voiceAria": "Hlas",
"podcast.voiceAuto": "Automaticky (nejlepší dostupný)",
"podcast.voiceLocal": "nainstalovaný",
"podcast.voiceNote": "Hlasy dodává prohlížeč a systém — vyber si ten, co zní nejlíp.",
"podcast.voiceRobotic": "Jsou nainstalované jen základní systémové hlasy. Pro přirozený hlas moderátora si nějaký přidej v nastavení systému (Windows: Řeč; macOS: Mluvený obsah; Chrome má hlasy Google).",
"podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
"podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
"podcast.loadingEpisode": "Načítání epizody",

View file

@ -16,7 +16,7 @@
* "jump to a chapter" and "resume where you left off" the same operation.
*/
import { pickVoice, speechAvailable } from "./speech";
import { speechAvailable } from "./speech";
import type { PodcastSection } from "./schemas";
export type PodcastVoice = { section: number; word: number };
@ -25,6 +25,8 @@ export type PodcastSpeechEvents = {
sections: PodcastSection[];
locale: string;
rate: number;
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
voice?: SpeechSynthesisVoice | null;
/** Global word position changed (word = index inside its section). */
onWord: (position: PodcastVoice) => void;
/** Playback actually began (after the engine accepted it). */
@ -134,8 +136,7 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = events.locale;
utterance.rate = events.rate;
const voice = pickVoice(events.locale);
if (voice) utterance.voice = voice;
if (events.voice) utterance.voice = events.voice;
const offsetFor = (charIndex: number) => {
/* First token whose span ends after the reported character. */

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;