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

@ -1516,7 +1516,16 @@ Things I chose while building, with the reasoning:
contrast, and the scan now covers this screen too. `tests/podcast-test.mjs` (17th suite)
stubs both the model and the speech engine: it drives a boundary event, checks the
pause/resume word, the chapter jump, the speed change, the stored position and the plugin's
400/stream guards (26 checks).
400/stream guards. **Follow-up (0.7.4):** the scroll 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 instead of animated, and a manual wheel,
touch or drag pauses the following for four seconds instead of fighting the reader; a seek
(chapter, word tap) re-centres at once. And the voice is now a choice: `lib/podcast-voices`
ranks what the platform has installed (natural/neural/online names up, espeak/SAM/compact
down, a male name preferred — "preferably male" was the ask), the player picks the best one
automatically and offers a picker for everything in the episode's language, and the choice is
remembered per language. The suite grew to 34 checks (voice ranking, the filtered picker,
persisting a pick, same-line silence, line following, manual-scroll suspension).
102. **An API key is not a login.** "When trying to use a different provider on the web app it
glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid

View file

@ -4,12 +4,32 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { createPodcastSpeech, tokenizeSections, type PodcastVoice } from "@/lib/podcast-speech";
import {
createPodcastSpeech,
tokenizeSections,
type PodcastVoice,
} from "@/lib/podcast-speech";
import { savePodcastProgress } from "@/lib/podcasts";
import {
bestVoice,
getStoredVoice,
onlyRoboticVoices,
platformVoices,
setStoredVoice,
voicesFor,
} from "@/lib/podcast-voices";
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
import type { Podcast } from "@/lib/schemas";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { ChevronLeftIcon, ClockIcon, PauseIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import {
CheckCircleIcon,
ChevronLeftIcon,
ClockIcon,
PauseIcon,
PlayIcon,
RefreshIcon,
SpeakerIcon,
} from "@/components/icons";
import { cn } from "@/lib/utils";
/** Playback speeds, cycled by one button. */
@ -32,20 +52,73 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
const [playing, setPlaying] = useState(false);
const [position, setPosition] = useState<PodcastVoice | null>(null);
const [unsupported, setUnsupported] = useState(false);
const [voiceOpen, setVoiceOpen] = useState(false);
const transcript = useRef<HTMLDivElement>(null);
const activeWord = useRef<HTMLElement | null>(null);
/** Line-following state: the last followed line and a manual-scroll pause. */
const lastLineTop = useRef<number | null>(null);
const followPausedUntil = useRef(0);
const voiceLocale = useMemo(
() => speechLocale(episode.language, locale),
[episode.language, locale],
);
const [voiceList, setVoiceList] = useState<SpeechSynthesisVoice[]>([]);
const [voiceURI, setVoiceURI] = useState<string | null>(() =>
getStoredVoice(voiceLocale),
);
/* The voice catalogue arrives asynchronously (and changes when the OS
installs one), so subscribe rather than read it once. */
useEffect(() => {
const read = () => setVoiceList(platformVoices());
read();
try {
window.speechSynthesis?.addEventListener("voiceschanged", read);
} catch {
/* no voices API */
}
return () => {
try {
window.speechSynthesis?.removeEventListener("voiceschanged", read);
} catch {
/* already gone */
}
};
}, []);
const availableVoices = useMemo(
() => voicesFor(voiceLocale, voiceList),
[voiceLocale, voiceList],
);
const voice = useMemo(() => {
if (voiceURI) {
const match = availableVoices.find(
(entry) => entry.voiceURI === voiceURI,
);
if (match) return match;
}
return bestVoice(voiceLocale, voiceList);
}, [voiceURI, availableVoices, voiceLocale, voiceList]);
const roboticOnly = useMemo(
() => onlyRoboticVoices(voiceLocale, voiceList),
[voiceLocale, voiceList],
);
/** Latest position for the unmount/pause save, without re-creating callbacks. */
const latest = useRef<PodcastVoice | null>(episode.progress ?? null);
const tokens = useMemo(() => tokenizeSections(episode.sections), [episode.sections]);
const tokens = useMemo(
() => tokenizeSections(episode.sections),
[episode.sections],
);
const supported = useMemo(() => speechAvailable(), []);
const speech = useMemo(() => {
if (!supported) return null;
return createPodcastSpeech({
sections: episode.sections,
locale: speechLocale(episode.language, locale),
locale: voiceLocale,
rate,
voice,
onWord: (next) => {
latest.current = next;
setPosition(next);
@ -53,7 +126,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
onPlay: () => {
setPlaying(true);
/* The Aero soundtrack steps aside for the voice (see AeroMusic). */
window.dispatchEvent(new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }));
window.dispatchEvent(
new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }),
);
},
onPause: () => setPlaying(false),
onEnd: () => {
@ -67,14 +142,19 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
setPlaying(false);
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- rebuilt only when the episode, the speed or the locale changes
}, [episode.id, episode.sections, rate, supported, locale]);
}, [episode.id, episode.sections, rate, supported, voiceLocale, voice]);
/* Resume where the listener left off (a reopened episode starts there). */
useEffect(() => {
if (episode.progress) {
latest.current = { section: episode.progress.section, word: episode.progress.word };
setPosition({ section: episode.progress.section, word: episode.progress.word });
latest.current = {
section: episode.progress.section,
word: episode.progress.word,
};
setPosition({
section: episode.progress.section,
word: episode.progress.word,
});
}
}, [episode.id, episode.progress]);
@ -127,7 +207,10 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
}, [speech]);
/* The highlight: one class on the active word (imperative, so a few hundred
spans do not re-render per word), then keep it in the comfortable band. */
spans do not re-render per word). The scroll follows by *line*: words on
the same line never move anything, a line only scrolls once it leaves the
comfortable band, and a manual scroll pauses the following for a moment
instead of fighting the reader. */
useEffect(() => {
const container = transcript.current;
if (!container) return;
@ -137,9 +220,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
activeWord.current = null;
return;
}
const index = tokens.findIndex((token) => token.section === position.section && token.index === position.word);
const index = tokens.findIndex(
(token) =>
token.section === position.section && token.index === position.word,
);
if (index === -1) return;
const element = container.querySelector<HTMLElement>(`[data-word="${index}"]`);
const element = container.querySelector<HTMLElement>(
`[data-word="${index}"]`,
);
if (!element) return;
element.setAttribute("data-word-active", "1");
element.classList.add("podcast-word-active");
@ -148,20 +236,50 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
const box = element.getBoundingClientRect();
const view = container.getBoundingClientRect();
const top = box.top - view.top;
const comfortable = view.height * 0.38;
if (top < view.height * 0.18 || top > view.height * 0.62) {
/* Same line as last time (a wrapped word shifts a pixel or two): nothing
to do — this is what stops the jitter under every word. */
const previous = lastLineTop.current;
const sameLine = previous !== null && Math.abs(top - previous) < 8;
lastLineTop.current = top;
if (sameLine) return;
/* The reader scrolled away on purpose: let them read for a moment. */
if (Date.now() < followPausedUntil.current) return;
const comfortable = view.height * 0.42;
const delta = top - comfortable;
/* Inside the band already: leave the page alone. */
if (Math.abs(delta) < view.height * 0.14) return;
container.scrollTo({
top: container.scrollTop + top - comfortable,
behavior: reduceMotion ? "auto" : "smooth",
top: container.scrollTop + delta,
behavior: Math.abs(delta) > 80 && !reduceMotion ? "smooth" : "auto",
});
}
}, [position, tokens, reduceMotion]);
/* A wheel, a drag or a touch on the transcript is the reader taking over. */
useEffect(() => {
const container = transcript.current;
if (!container) return;
const suspend = () => {
followPausedUntil.current = Date.now() + 4000;
};
container.addEventListener("wheel", suspend, { passive: true });
container.addEventListener("touchstart", suspend, { passive: true });
container.addEventListener("pointerdown", suspend, { passive: true });
return () => {
container.removeEventListener("wheel", suspend);
container.removeEventListener("touchstart", suspend);
container.removeEventListener("pointerdown", suspend);
};
}, []);
const sections = episode.sections;
const words = tokens.length;
const done = useMemo(() => {
if (!position) return 0;
return tokens.findIndex((token) => token.section === position.section && token.index === position.word);
return tokens.findIndex(
(token) =>
token.section === position.section && token.index === position.word,
);
}, [position, tokens]);
const toggle = () => {
@ -176,37 +294,43 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
speech.play(position ?? { section: 0, word: 0 });
};
const jump = (section: number) => {
const seek = (next: PodcastVoice) => {
if (!speech) return;
const next = { section, word: 0 };
latest.current = next;
setPosition(next);
/* A seek is an instruction to show that place: follow it at once. */
lastLineTop.current = null;
followPausedUntil.current = 0;
stopSpeaking();
speech.play(next);
};
const jump = (section: number) => seek({ section, word: 0 });
const jumpWord = (index: number) => {
if (!speech) return;
const token = tokens[index];
if (!token) return;
const next = { section: token.section, word: token.index };
latest.current = next;
setPosition(next);
stopSpeaking();
speech.play(next);
seek({ section: token.section, word: token.index });
};
/* Word spans, built once per section: the highlight is a class flip. */
const rendered = useMemo(() => {
return sections.map((section, sectionIndex) => {
const sectionTokens = tokens.filter((token) => token.section === sectionIndex);
const sectionTokens = tokens.filter(
(token) => token.section === sectionIndex,
);
const nodes: React.ReactNode[] = [];
let cursor = 0;
sectionTokens.forEach((token) => {
if (token.start > cursor) nodes.push(section.text.slice(cursor, token.start));
if (token.start > cursor)
nodes.push(section.text.slice(cursor, token.start));
const globalIndex = token.global;
nodes.push(
<span key={`${token.start}`} data-word={globalIndex} className="podcast-word">
<span
key={`${token.start}`}
data-word={globalIndex}
className="podcast-word"
>
{section.text.slice(token.start, token.end)}
</span>,
);
@ -232,13 +356,35 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
<ChevronLeftIcon className="size-4" />
</Link>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">{episode.title}</p>
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
{episode.title}
</p>
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
{t("podcast.sectionOf", { n: Math.min(currentSection + 1, sections.length), total: sections.length })}
{t("podcast.sectionOf", {
n: Math.min(currentSection + 1, sections.length),
total: sections.length,
})}
{" · "}
{sections[currentSection]?.heading ?? ""}
</p>
</div>
{availableVoices.length > 0 ? (
<button
type="button"
onClick={() => setVoiceOpen((open) => !open)}
aria-label={t("podcast.voiceAria")}
aria-expanded={voiceOpen}
title={voice?.name ?? t("podcast.voiceAria")}
className={cn(
"grid size-10 shrink-0 place-items-center rounded-full transition",
voiceOpen
? "bg-[var(--nav-active-bg)] text-[var(--text)]"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
<SpeakerIcon className="size-4" />
</button>
) : null}
<button
type="button"
onClick={() => {
@ -278,11 +424,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
{episode.title}
</h1>
{episode.summary ? (
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">{episode.summary}</p>
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">
{episode.summary}
</p>
) : null}
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
<ClockIcon className="size-3.5" />
{plural(episode.sections.length, "podcast.sectionCount")} · {plural(words, "podcast.wordCount")}
{plural(episode.sections.length, "podcast.sectionCount")} ·{" "}
{plural(words, "podcast.wordCount")}
</p>
</div>
@ -310,18 +459,98 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
))}
</div>
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">{t("podcast.tapToJump")}</p>
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">
{t("podcast.tapToJump")}
</p>
</div>
</div>
{/* Controls: chapter list on the left, transport in the middle. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 pb-safe">
<div className="pointer-events-auto mx-auto max-w-3xl px-3 pb-3">
{voiceOpen ? (
<div className="pointer-events-auto mx-auto mb-2 w-full max-w-md px-0">
<div className="glass-panel max-h-72 space-y-1 overflow-y-auto overscroll-contain p-3">
<div className="flex items-center justify-between gap-2 px-1">
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("podcast.voiceAria")}
</p>
<button
type="button"
onClick={() => setVoiceOpen(false)}
className="text-[12.5px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("common.close")}
</button>
</div>
{voiceURI ? (
<button
type="button"
onClick={() => {
setVoiceURI(null);
window.localStorage.removeItem(
"freezmo.podcast-voice.v1",
);
}}
className="nav-row touch-target w-full text-left text-[13px]"
>
{t("podcast.voiceAuto")}
</button>
) : null}
{availableVoices.map((entry) => {
const active = entry.voiceURI === voice?.voiceURI;
return (
<button
key={entry.voiceURI}
type="button"
aria-pressed={active}
onClick={() => {
setVoiceURI(entry.voiceURI);
setStoredVoice(voiceLocale, entry.voiceURI);
setVoiceOpen(false);
}}
className={cn(
"nav-row touch-target flex w-full items-center gap-2 text-left text-[13px]",
active &&
"border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-[var(--text)]">
{entry.name}
</span>
<span className="block text-[11.5px] text-[var(--text-subtle)]">
{entry.lang}
{entry.localService
? ` · ${t("podcast.voiceLocal")}`
: ""}
</span>
</span>
{active ? (
<CheckCircleIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
) : null}
</button>
);
})}
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{roboticOnly
? t("podcast.voiceRobotic")
: t("podcast.voiceNote")}
</p>
</div>
</div>
) : null}
<div className="study-chrome rounded-[1.4rem] px-3 py-2.5">
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--track)]" aria-hidden>
<div
className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--track)]"
aria-hidden
>
<div
className="h-full rounded-full grad-ice transition-[width] duration-200"
style={{ width: words ? `${Math.round((done / words) * 100)}%` : "0%" }}
style={{
width: words ? `${Math.round((done / words) * 100)}%` : "0%",
}}
/>
</div>
<div className="flex items-center gap-2">
@ -333,7 +562,11 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
title={playing ? t("podcast.pause") : t("podcast.play")}
className="grid size-12 shrink-0 place-items-center rounded-full grad-ice text-[#06111f] transition disabled:opacity-45"
>
{playing ? <PauseIcon className="size-5" /> : <PlayIcon className="size-5" />}
{playing ? (
<PauseIcon className="size-5" />
) : (
<PlayIcon className="size-5" />
)}
</button>
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{sections.map((section, index) => (

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;

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.7.3",
"version": "0.7.4",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v95";
const CACHE_VERSION = "freezmo-v96";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -36,6 +36,48 @@ const EPISODE_REPLY = {
],
};
/** An episode seeded straight into storage, for the voice/scroll sections. */
const EPISODE_RECORD = {
id: "pod_local",
title: "The sky, out loud",
summary: "Rayleigh scattering.",
language: "English",
sourceText: "Rayleigh scattering.",
createdAt: Date.now(),
updatedAt: Date.now(),
sections: [
{ heading: "Daylight", text: "Look up on a clear day. The sky is blue. Every direction you look, the same blue." },
{ heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones, so blue reaches your eyes from everywhere at once." },
],
};
/** Long enough to scroll: eight paragraphs of a few lines each. */
const LONG_RECORD = {
...EPISODE_RECORD,
id: "pod_long",
title: "A long walk through the atmosphere",
sections: Array.from({ length: 8 }, (_, index) => ({
heading: `Part ${index + 1}`,
text: `Paragraph ${index + 1}. ${"The atmosphere scatters light in every direction, and the shorter wavelengths bounce far more often than the longer ones. ".repeat(3)}`,
})),
};
/** A speech engine that never reports boundaries: the timer clock drives it. */
const WORD_TIMER_SPEECH = `
window.__utterances = [];
class FakeUtterance {
constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; }
}
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: {
speak(utterance) { window.__utterances.push(utterance); queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); },
cancel() {}, getVoices() { return []; }, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false,
},
});
`;
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
@ -220,6 +262,118 @@ try {
);
await context.close();
/* 4b. voices: the best installed one, and a picker that sticks ------ */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
/* A catalogue that looks like a bare Linux box next to Chrome: the robot
must lose to the natural male voice. */
await page.addInitScript(() => {
window.__spoken = [];
const voices = [
{ name: "eSpeak English", lang: "en-US", voiceURI: "espeak", localService: true, default: true },
{ name: "Google US English", lang: "en-US", voiceURI: "g-us", localService: false, default: false },
{ name: "Google UK English Male", lang: "en-GB", voiceURI: "g-uk-male", localService: false, default: false },
{ name: "Czech (Czech Republic)", lang: "cs-CZ", voiceURI: "cs-cz", localService: true, default: false },
];
class FakeUtterance {
constructor(text) {
this.text = String(text);
this.rate = 1;
this.lang = "en-US";
this.voice = null;
this.onstart = null;
this.onend = null;
this.onerror = null;
this.onboundary = null;
}
}
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: {
speak(utterance) {
window.__spoken.push({ text: utterance.text, voice: utterance.voice?.voiceURI ?? null });
queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 }));
},
cancel() {},
getVoices: () => voices,
addEventListener() {},
removeEventListener() {},
speaking: false,
paused: false,
},
});
});
await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } });
await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
await page.getByRole("button", { name: "Play the episode" }).click();
await page.waitForTimeout(400);
const firstVoice = await page.evaluate(() => window.__spoken.at(-1)?.voice);
check("voice: the natural male voice beats the robot", firstVoice === "g-uk-male", String(firstVoice));
await page.getByRole("button", { name: "Voice" }).click();
await page.waitForTimeout(300);
const listed = await page.evaluate(() =>
[...document.querySelectorAll("[aria-pressed]")].map((el) => el.textContent?.trim() ?? ""),
);
check("voice: the picker lists the voices for the language", listed.some((entry) => entry.includes("Google UK English Male")), JSON.stringify(listed.slice(0, 4)));
check("voice: other languages are filtered out", !listed.some((entry) => entry.includes("Czech")), JSON.stringify(listed));
await page.getByRole("button", { name: /Google US English/ }).click();
await page.waitForTimeout(500);
const chosen = await page.evaluate(() => window.__spoken.at(-1)?.voice);
check("voice: choosing one speaks with it", chosen === "g-us", String(chosen));
const stored = await page.evaluate(() => localStorage.getItem("freezmo.podcast-voice.v1"));
check("voice: the choice is remembered", String(stored).includes("g-us"), String(stored));
await context.close();
}
/* 4c. the transcript follows by line, and yields to a manual scroll --- */
{
const { context, page } = await makePage(browser, { width: 390, height: 700, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
await page.addInitScript(WORD_TIMER_SPEECH);
await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([LONG_RECORD]) } });
await page.goto(`${APP}/podcast/pod_long`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
await page.getByRole("button", { name: "Play the episode" }).click();
await page.waitForTimeout(400);
const scrollTop = () => page.evaluate(() => document.querySelector("[data-testid=podcast-transcript]")?.scrollTop ?? -1);
const boundary = (charIndex) =>
page.evaluate((index) => {
const utterance = window.__utterances.at(-1);
utterance.onboundary({ name: "word", charIndex: index });
}, charIndex);
/* Three words into the same line: the page must not move. */
const start = await scrollTop();
await boundary(3);
await boundary(6);
await boundary(9);
await page.waitForTimeout(400);
check("scroll: words on the same line do not move the page", (await scrollTop()) === start, `${start} -> ${await scrollTop()}`);
/* A word far into the script: the page follows it. */
await boundary(900);
await page.waitForTimeout(700);
const followed = await scrollTop();
check("scroll: a new line is followed", followed > start, `${start} -> ${followed}`);
/* The reader scrolls on purpose: following pauses instead of fighting. */
await page.evaluate(() => {
const container = document.querySelector("[data-testid=podcast-transcript]");
container.scrollTop = 0;
container.dispatchEvent(new WheelEvent("wheel", { bubbles: true }));
});
await page.waitForTimeout(200);
const manual = await scrollTop();
await boundary(1400);
await page.waitForTimeout(500);
check("scroll: a manual scroll is left alone", (await scrollTop()) === manual, `${manual} -> ${await scrollTop()}`);
await context.close();
}
/* 5. server guards ---------------------------------------------- */
const bad = await fetch(`${APP}/api/podcast`, {
method: "POST",