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

@ -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,7 +248,19 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
}
};
window.speechSynthesis.speak(utterance);
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");
@ -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 */
}