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:
parent
d95c5c7151
commit
4ec986da12
8 changed files with 684 additions and 46 deletions
11
README.md
11
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
231
lib/podcast-voices.ts
Normal 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;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.4",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue