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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue