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
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue