diff --git a/README.md b/README.md index 60e8a3f..5e65982 100644 --- a/README.md +++ b/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 diff --git a/components/podcast/PodcastPlayer.tsx b/components/podcast/PodcastPlayer.tsx index 17841ef..8462625 100644 --- a/components/podcast/PodcastPlayer.tsx +++ b/components/podcast/PodcastPlayer.tsx @@ -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(null); const [unsupported, setUnsupported] = useState(false); + const [voiceOpen, setVoiceOpen] = useState(false); const transcript = useRef(null); const activeWord = useRef(null); + /** Line-following state: the last followed line and a manual-scroll pause. */ + const lastLineTop = useRef(null); + const followPausedUntil = useRef(0); + const voiceLocale = useMemo( + () => speechLocale(episode.language, locale), + [episode.language, locale], + ); + const [voiceList, setVoiceList] = useState([]); + const [voiceURI, setVoiceURI] = useState(() => + 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(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(`[data-word="${index}"]`); + const element = container.querySelector( + `[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) { - container.scrollTo({ - top: container.scrollTop + top - comfortable, - behavior: reduceMotion ? "auto" : "smooth", - }); - } + /* 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 + 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( - + {section.text.slice(token.start, token.end)} , ); @@ -232,13 +356,35 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
-

{episode.title}

+

+ {episode.title} +

- {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 ?? ""}

+ {availableVoices.length > 0 ? ( + + ) : null} + + {voiceURI ? ( + + ) : null} + {availableVoices.map((entry) => { + const active = entry.voiceURI === voice?.voiceURI; + return ( + + ); + })} +

+ {roboticOnly + ? t("podcast.voiceRobotic") + : t("podcast.voiceNote")} +

+ + + ) : null} +
-
+
@@ -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 ? : } + {playing ? ( + + ) : ( + + )}
{sections.map((section, index) => ( diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index b44368e..6fe5689 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -302,6 +302,11 @@ const en: Record = { "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 = { "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", diff --git a/lib/podcast-speech.ts b/lib/podcast-speech.ts index f08d874..da304a0 100644 --- a/lib/podcast-speech.ts +++ b/lib/podcast-speech.ts @@ -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. */ diff --git a/lib/podcast-voices.ts b/lib/podcast-voices.ts new file mode 100644 index 0000000..f2f013b --- /dev/null +++ b/lib/podcast-voices.ts @@ -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; + +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)) { + 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; diff --git a/package.json b/package.json index 3587d36..572e976 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.7.3", + "version": "0.7.4", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 4c3a44e..6ec2e7c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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`; diff --git a/tests/podcast-test.mjs b/tests/podcast-test.mjs index 60dae2a..ef89119 100644 --- a/tests/podcast-test.mjs +++ b/tests/podcast-test.mjs @@ -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,7 +262,119 @@ try { ); await context.close(); - /* 5. server guards ---------------------------------------------- */ + /* 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", headers: { "Content-Type": "application/json" },