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
14
README.md
14
README.md
|
|
@ -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
|
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
||||||
scene-heat rework unchanged.
|
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:
|
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 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,
|
the browser/OS TTS (iOS and several Linux engines pop between their internal sentence chunks,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
natural?.setRate(rate);
|
natural?.setRate(rate);
|
||||||
}, [natural, rate]);
|
}, [natural, rate]);
|
||||||
|
|
||||||
|
|
||||||
/* The engine owns an <audio> element: release it when the engine is
|
/* The engine owns an <audio> element: release it when the engine is
|
||||||
replaced (a different voice) or the player closes. */
|
replaced (a different voice) or the player closes. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -206,13 +207,24 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
);
|
);
|
||||||
const supported = useMemo(() => speechAvailable(), []);
|
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(() => {
|
const speech = useMemo(() => {
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
return createPodcastSpeech({
|
return createPodcastSpeech({
|
||||||
sections: episode.sections,
|
sections: episode.sections,
|
||||||
locale: voiceLocale,
|
locale: voiceLocale,
|
||||||
rate,
|
getRate: () => rateRef.current,
|
||||||
voice,
|
getVoice: () => voiceRef.current,
|
||||||
onWord: (next) => {
|
onWord: (next) => {
|
||||||
latest.current = next;
|
latest.current = next;
|
||||||
setPosition(next);
|
setPosition(next);
|
||||||
|
|
@ -236,7 +248,7 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
setPlaying(false);
|
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). */
|
/* Resume where the listener left off (a reopened episode starts there). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -380,6 +392,31 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
);
|
);
|
||||||
}, [position, tokens]);
|
}, [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);
|
const naturalActive = voiceSource === "natural" && Boolean(natural);
|
||||||
/** The engines share their shape; the transport routes by source. */
|
/** The engines share their shape; the transport routes by source. */
|
||||||
const canPlay = naturalActive ? true : supported;
|
const canPlay = naturalActive ? true : supported;
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ export function normaliseSpoken(raw: string): string {
|
||||||
export type PodcastSpeechEvents = {
|
export type PodcastSpeechEvents = {
|
||||||
sections: PodcastSection[];
|
sections: PodcastSection[];
|
||||||
locale: string;
|
locale: string;
|
||||||
rate: number;
|
/** Read live, so speed and voice changes never rebuild a speaking engine. */
|
||||||
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
|
getRate?: () => number;
|
||||||
voice?: SpeechSynthesisVoice | null;
|
getVoice?: () => SpeechSynthesisVoice | null;
|
||||||
/** Global word position changed (word = index inside its section). */
|
/** Global word position changed (word = index inside its section). */
|
||||||
onWord: (position: PodcastVoice) => void;
|
onWord: (position: PodcastVoice) => void;
|
||||||
/** Playback actually began (after the engine accepted it). */
|
/** Playback actually began (after the engine accepted it). */
|
||||||
|
|
@ -102,6 +102,9 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
/** Cleared by cancel(): stops stale timers/events from a previous utterance. */
|
/** Cleared by cancel(): stops stale timers/events from a previous utterance. */
|
||||||
let generation = 0;
|
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 = () => {
|
const clearTimer = () => {
|
||||||
if (timer !== null) {
|
if (timer !== null) {
|
||||||
|
|
@ -146,13 +149,22 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
|
||||||
try {
|
try {
|
||||||
/* Only cancel when something is actually pending: an idle cancel makes
|
/* Only cancel when something is actually pending: an idle cancel makes
|
||||||
several engines pop. */
|
several engines pop. */
|
||||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
const synth = window.speechSynthesis;
|
||||||
window.speechSynthesis.cancel();
|
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);
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
utterance.lang = events.locale;
|
utterance.lang = events.locale;
|
||||||
utterance.rate = events.rate;
|
utterance.rate = Math.min(3, Math.max(0.3, events.getRate?.() ?? 1));
|
||||||
if (events.voice) utterance.voice = events.voice;
|
const voice = events.getVoice?.() ?? null;
|
||||||
|
if (voice) utterance.voice = voice;
|
||||||
|
|
||||||
const offsetFor = (charIndex: number) => {
|
const offsetFor = (charIndex: number) => {
|
||||||
/* First token whose span ends after the reported character. */
|
/* 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 };
|
position = { section: target.section, word: index };
|
||||||
events.onWord(position);
|
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);
|
window.speechSynthesis.speak(utterance);
|
||||||
} catch {
|
} catch {
|
||||||
playing = false;
|
playing = false;
|
||||||
events.onError?.("error");
|
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 {
|
return {
|
||||||
play(from) {
|
play(from) {
|
||||||
|
|
@ -251,7 +275,14 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
|
||||||
generation += 1;
|
generation += 1;
|
||||||
clearTimer();
|
clearTimer();
|
||||||
try {
|
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 {
|
} catch {
|
||||||
/* already gone */
|
/* already gone */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.7.6",
|
"version": "0.7.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ const browser = await launchBrowser();
|
||||||
const SPEECH_STUB = `
|
const SPEECH_STUB = `
|
||||||
window.__spoken = [];
|
window.__spoken = [];
|
||||||
window.__cancelCount = 0;
|
window.__cancelCount = 0;
|
||||||
|
window.__droppedWhilePaused = 0;
|
||||||
window.__utterances = [];
|
window.__utterances = [];
|
||||||
class FakeUtterance {
|
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; }
|
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", {
|
Object.defineProperty(window, "speechSynthesis", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
|
_speaking: false,
|
||||||
|
_paused: false,
|
||||||
|
get speaking() { return this._speaking; },
|
||||||
|
get pending() { return false; },
|
||||||
|
get paused() { return this._paused; },
|
||||||
speak(utterance) {
|
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.__spoken.push(utterance.text);
|
||||||
window.__utterances.push(utterance);
|
window.__utterances.push(utterance);
|
||||||
|
this._speaking = true;
|
||||||
queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 }));
|
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 []; },
|
getVoices() { return []; },
|
||||||
speaking: false,
|
|
||||||
paused: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
`;
|
`;
|
||||||
|
|
@ -222,6 +232,11 @@ try {
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
const resumed = await page.evaluate(() => window.__spoken.at(-1));
|
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: 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 ------------------------------ */
|
/* 3. chapters, speed, progress save ------------------------------ */
|
||||||
await page.getByRole("button", { name: "Why", exact: true }).click();
|
await page.getByRole("button", { name: "Why", exact: true }).click();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue