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:
parent
8e1cd0855d
commit
5d82612e2a
6 changed files with 116 additions and 19 deletions
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue