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

@ -1492,6 +1492,20 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged.
105. **The play button that only worked once.** "It only plays once, and when you pause it you can't
unpause." WebKit's quirk, faithfully: `speechSynthesis.cancel()` leaves the synthesis **paused**,
and every later `speak()` is queued into that paused state — silently. Three fixes in the
system engine: `resume()` whenever the synth is paused before speaking (and after our own
cancel), a ~90 ms beat before a `speak()` that follows a cancel (several engines, Android
Chrome included, drop it otherwise), and no more unnecessary cancels. The engine also stopped
being rebuilt by speed or voice changes — both are read live through getters now, so a
`voiceschanged` that arrives after the first utterance can no longer leave a second engine
speaking over the first; a rate or voice change instead restarts the current part from the
same word (the Web Speech API cannot change either mid-utterance), which is what makes the
change audible at once. The suite's speech stub now *models* the quirk — a cancel pauses it
and a speak while paused is dropped and counted — and pins the fix: nothing may be spoken into
a paused engine (44 checks).
104. **Natural voices — and the crack at the end of every sentence.** Two reports, one root:
the platform's speech engine. "There's a loud cracking sound at the end of every sentence" is
the browser/OS TTS (iOS and several Linux engines pop between their internal sentence chunks,

View file

@ -112,6 +112,7 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
natural?.setRate(rate);
}, [natural, rate]);
/* The engine owns an <audio> element: release it when the engine is
replaced (a different voice) or the player closes. */
useEffect(() => {
@ -206,13 +207,24 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
);
const supported = useMemo(() => speechAvailable(), []);
/* Live getters: changing the speed or the voice must never rebuild a
speaking engine (a rebuilt one used to leave the old voice running). */
const rateRef = useRef(rate);
const voiceRef = useRef<SpeechSynthesisVoice | null>(null);
useEffect(() => {
rateRef.current = rate;
}, [rate]);
useEffect(() => {
voiceRef.current = voice;
}, [voice]);
const speech = useMemo(() => {
if (!supported) return null;
return createPodcastSpeech({
sections: episode.sections,
locale: voiceLocale,
rate,
voice,
getRate: () => rateRef.current,
getVoice: () => voiceRef.current,
onWord: (next) => {
latest.current = next;
setPosition(next);
@ -236,7 +248,7 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
setPlaying(false);
},
});
}, [episode.id, episode.sections, rate, supported, voiceLocale, voice]);
}, [episode.id, episode.sections, supported, voiceLocale]);
/* Resume where the listener left off (a reopened episode starts there). */
useEffect(() => {
@ -380,6 +392,31 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
);
}, [position, tokens]);
/* The system voice has neither a live rate nor a live voice: restart the
current part so a change is heard at once (the natural engine above is
live, so this only runs on the system path). */
const firstRestart = useRef(true);
useEffect(() => {
if (firstRestart.current) {
firstRestart.current = false;
return;
}
if (!playingRef.current || naturalActive || !speech) return;
speech.play(latest.current ?? { section: 0, word: 0 });
// eslint-disable-next-line react-hooks/exhaustive-deps -- restarts only when the rate or the voice changes
}, [rate, voice]);
/* A rebuilt natural engine (a different voice) resumes where it was. */
const firstNatural = useRef(true);
useEffect(() => {
if (firstNatural.current) {
firstNatural.current = false;
return;
}
if (!playingRef.current || !natural) return;
natural.play(latest.current ?? { section: 0, word: 0 });
}, [natural]);
const naturalActive = voiceSource === "natural" && Boolean(natural);
/** The engines share their shape; the transport routes by source. */
const canPlay = naturalActive ? true : supported;

View file

@ -34,9 +34,9 @@ export function normaliseSpoken(raw: string): string {
export type PodcastSpeechEvents = {
sections: PodcastSection[];
locale: string;
rate: number;
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
voice?: SpeechSynthesisVoice | null;
/** Read live, so speed and voice changes never rebuild a speaking engine. */
getRate?: () => number;
getVoice?: () => SpeechSynthesisVoice | null;
/** Global word position changed (word = index inside its section). */
onWord: (position: PodcastVoice) => void;
/** Playback actually began (after the engine accepted it). */
@ -102,6 +102,9 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
let timer: number | null = null;
/** Cleared by cancel(): stops stale timers/events from a previous utterance. */
let generation = 0;
/** A cancel in the last moments: some engines drop a speak() that follows
* one immediately, so it gets a tick of air. */
let lastCancelAt = 0;
const clearTimer = () => {
if (timer !== null) {
@ -146,13 +149,22 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
try {
/* Only cancel when something is actually pending: an idle cancel makes
several engines pop. */
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
window.speechSynthesis.cancel();
const synth = window.speechSynthesis;
const cancelNeeded = synth.speaking || synth.pending;
const cancelledRecently = Date.now() - lastCancelAt < 400;
if (cancelNeeded) {
synth.cancel();
lastCancelAt = Date.now();
}
/* WebKit leaves the synthesis paused after a cancel (and sometimes
pauses it on its own): without this resume the next speak() queues
silently and the player looks dead after one listen. */
if (synth.paused) synth.resume();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = events.locale;
utterance.rate = events.rate;
if (events.voice) utterance.voice = events.voice;
utterance.rate = Math.min(3, Math.max(0.3, events.getRate?.() ?? 1));
const voice = events.getVoice?.() ?? null;
if (voice) utterance.voice = voice;
const offsetFor = (charIndex: number) => {
/* First token whose span ends after the reported character. */
@ -198,9 +210,9 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
}
position = { section: target.section, word: index };
events.onWord(position);
timer = window.setTimeout(tick, wordDuration(sectionTokens[index].text) / events.rate);
timer = window.setTimeout(tick, wordDuration(sectionTokens[index].text) / (events.getRate?.() ?? 1));
};
timer = window.setTimeout(tick, wordDuration(sectionTokens[word].text) / events.rate);
timer = window.setTimeout(tick, wordDuration(sectionTokens[word].text) / (events.getRate?.() ?? 1));
}
};
@ -236,12 +248,24 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
}
};
const sayIt = () => {
if (generation !== current) return;
try {
window.speechSynthesis.speak(utterance);
} catch {
playing = false;
events.onError?.("error");
}
};
/* A speak() that follows a cancel immediately is dropped by several
engines (Android Chrome, WebKit); a short beat avoids it. */
if (cancelNeeded || cancelledRecently) window.setTimeout(sayIt, 90);
else sayIt();
} catch {
playing = false;
events.onError?.("error");
}
};
return {
play(from) {
@ -251,7 +275,14 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
generation += 1;
clearTimer();
try {
window.speechSynthesis.cancel();
const synth = window.speechSynthesis;
if (synth.speaking || synth.pending) {
synth.cancel();
lastCancelAt = Date.now();
}
/* Cancel leaves some engines (WebKit especially) paused; clear that
here so the next play is not a silent no-op. */
if (synth.paused) synth.resume();
} catch {
/* already gone */
}

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.7.6",
"version": "0.7.7",
"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-v98";
const CACHE_VERSION = "freezmo-v99";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

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();