Podcast player: pause no longer kills the system voice

"It only plays once, and when you pause it you can't unpause."

WebKit leaves speechSynthesis paused after a cancel(), and every later
speak() is queued into that paused state - silently. Fixes:
- resume() whenever the synth is paused, before speaking and after our own
  cancel;
- a ~90ms beat before a speak() that follows a cancel (several engines drop
  it otherwise);
- no cancel when nothing is pending.

The speech engine also no longer rebuilds on rate or voice changes: both are
read live through getters, so a voiceschanged arriving after the first
utterance cannot leave a second engine speaking over the first. A rate or
voice change restarts the current part from the same word instead (the Web
Speech API cannot change either mid-utterance), making the change audible at
once; the natural engine applies speed live and resumes across a voice swap.

The suite's speech stub now models the quirk (cancel -> paused, speak while
paused is dropped and counted) and pins that nothing is ever spoken into a
paused engine. 44 checks. Version 0.7.7, SW v99.
This commit is contained in:
Martin Dvořák 2026-10-16 17:25:12 +00:00
commit 5d82612e2a
6 changed files with 116 additions and 19 deletions

View file

@ -134,6 +134,7 @@ const browser = await launchBrowser();
const SPEECH_STUB = `
window.__spoken = [];
window.__cancelCount = 0;
window.__droppedWhilePaused = 0;
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; }
@ -142,15 +143,24 @@ Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true,
Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: {
_speaking: false,
_paused: false,
get speaking() { return this._speaking; },
get pending() { return false; },
get paused() { return this._paused; },
speak(utterance) {
/* WebKit's quirk, on purpose: a cancel leaves the synthesis paused and
a speak() while paused is dropped on the floor. The app must resume
before it speaks again. */
if (this._paused) { window.__droppedWhilePaused += 1; return; }
window.__spoken.push(utterance.text);
window.__utterances.push(utterance);
this._speaking = true;
queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 }));
},
cancel() { window.__cancelCount += 1; },
cancel() { window.__cancelCount += 1; this._speaking = false; this._paused = true; },
resume() { this._paused = false; },
getVoices() { return []; },
speaking: false,
paused: false,
},
});
`;
@ -222,6 +232,11 @@ try {
await page.waitForTimeout(400);
const resumed = await page.evaluate(() => window.__spoken.at(-1));
check("player: resume starts at the current word", String(resumed).startsWith("clear day."), String(resumed).slice(0, 30));
check(
"player: nothing was spoken into a paused engine (the WebKit quirk)",
(await page.evaluate(() => window.__droppedWhilePaused)) === 0,
String(await page.evaluate(() => window.__droppedWhilePaused)),
);
/* 3. chapters, speed, progress save ------------------------------ */
await page.getByRole("button", { name: "Why", exact: true }).click();