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

@ -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;