fix(tts): prevent stale playback by tracking playback run state in TTS context

Introduce `playbackRunIdRef` and `invalidatePlaybackRun` to ensure that playback
state is correctly reset on abort and content changes. Update abort and navigation
handlers to call `invalidatePlaybackRun`, preventing duplicate or stale playback
runs, especially in scenarios with rapid restarts or browser-specific audio event
quirks. This addresses issues where playback could become stuck or behave
unexpectedly due to outdated in-flight guards.
This commit is contained in:
Richard R 2026-05-04 12:26:29 -06:00
parent 7d7e933323
commit 222d221e81

View file

@ -409,6 +409,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// and those guards taking effect, the effect can re-fire and start duplicate playback. // and those guards taking effect, the effect can re-fire and start duplicate playback.
// This is especially problematic in Firefox where HTML5 Audio events can cause extra renders. // This is especially problematic in Firefox where HTML5 Audio events can cause extra renders.
const playbackInFlightRef = useRef(false); const playbackInFlightRef = useRef(false);
const playbackRunIdRef = useRef(0);
// Track if we're restoring from a saved position // Track if we're restoring from a saved position
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null); const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
// Guard to coalesce rapid restarts and only resume the latest change // Guard to coalesce rapid restarts and only resume the latest change
@ -536,6 +537,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, []); }, []);
const invalidatePlaybackRun = useCallback(() => {
playbackRunIdRef.current += 1;
playbackInFlightRef.current = false;
}, []);
const isAutoplayBlockedError = useCallback((err: unknown) => { const isAutoplayBlockedError = useCallback((err: unknown) => {
const msg = (() => { const msg = (() => {
if (typeof err === 'string') return err; if (typeof err === 'string') return err;
@ -582,6 +588,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* @param {boolean} [clearPending=false] - Whether to clear pending requests * @param {boolean} [clearPending=false] - Whether to clear pending requests
*/ */
const abortAudio = useCallback((clearPending = false) => { const abortAudio = useCallback((clearPending = false) => {
// Ensure next playback attempt is not blocked by a stale in-flight guard.
invalidatePlaybackRun();
clearRateWatchdog(); clearRateWatchdog();
if (activeHowl) { if (activeHowl) {
activeHowl.stop(); activeHowl.stop();
@ -602,7 +610,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
pageTurnTimeoutRef.current = null; pageTurnTimeoutRef.current = null;
} }
setCurrentWordIndex(null); setCurrentWordIndex(null);
}, [activeHowl, clearRateWatchdog]); }, [activeHowl, clearRateWatchdog, invalidatePlaybackRun]);
/** /**
* Pauses the current audio playback while preserving seek position. * Pauses the current audio playback while preserving seek position.
@ -654,13 +662,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
// Reset state for new content in correct order // Reset state for new content in correct order
abortAudio(); invalidatePlaybackRun();
abortAudio(true);
if (shouldPause) setIsPlaying(false); if (shouldPause) setIsPlaying(false);
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]); setSentences([]);
setCurrDocPage(location); setCurrDocPage(location);
}, [abortAudio]); }, [abortAudio, invalidatePlaybackRun]);
/** /**
* Moves to the next or previous sentence * Moves to the next or previous sentence
@ -876,6 +885,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
// Keep track of previous state and pause playback // Keep track of previous state and pause playback
invalidatePlaybackRun();
setIsPlaying(false); setIsPlaying(false);
abortAudio(true); // Clear pending requests since text is changing abortAudio(true); // Clear pending requests since text is changing
setIsProcessing(true); // Set processing state before text processing starts setIsProcessing(true); // Set processing state before text processing starts
@ -965,7 +975,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
duration: 3000, duration: 3000,
}); });
}); });
}, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]); }, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting, invalidatePlaybackRun]);
useEffect(() => { useEffect(() => {
setTextRef.current = setText; setTextRef.current = setText;
@ -1011,9 +1021,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (isPlaying) { if (isPlaying) {
setIsProcessing(true); setIsProcessing(true);
} }
abortAudio(false); // Don't clear pending requests invalidatePlaybackRun();
abortAudio(true);
await advance(); await advance();
}, [isPlaying, abortAudio, advance]); }, [isPlaying, abortAudio, advance, invalidatePlaybackRun]);
/** /**
* Moves backward one sentence in the text * Moves backward one sentence in the text
@ -1023,9 +1034,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (isPlaying) { if (isPlaying) {
setIsProcessing(true); setIsProcessing(true);
} }
abortAudio(false); // Don't clear pending requests invalidatePlaybackRun();
abortAudio(true);
await advance(true); await advance(true);
}, [isPlaying, abortAudio, advance]); }, [isPlaying, abortAudio, advance, invalidatePlaybackRun]);
/** /**
* Updates the voice and speed settings from the configuration * Updates the voice and speed settings from the configuration
@ -1310,7 +1322,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* *
* @param {string} sentence - The sentence to play * @param {string} sentence - The sentence to play
*/ */
const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => { const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number, runId: number) => {
if (!sentence) { if (!sentence) {
console.log('No sentence to play'); console.log('No sentence to play');
playbackInFlightRef.current = false; playbackInFlightRef.current = false;
@ -1325,8 +1337,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
retryCount = 0, retryCount = 0,
useFallbackSource = false, useFallbackSource = false,
): Promise<Howl | null> => { ): Promise<Howl | null> => {
if (runId !== playbackRunIdRef.current) return null;
let playErrorAttempts = 0; let playErrorAttempts = 0;
const playbackSource = await processSentence(sentence, sentenceIndex); const playbackSource = await processSentence(sentence, sentenceIndex);
if (runId !== playbackRunIdRef.current) return null;
if (!playbackSource) { if (!playbackSource) {
// Graceful exit for rate limit / abort / intentionally skipped sentence // Graceful exit for rate limit / abort / intentionally skipped sentence
console.log('Skipping playback for sentence (no audio generated)'); console.log('Skipping playback for sentence (no audio generated)');
@ -1352,6 +1366,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
pool: 1, pool: 1,
rate: audioSpeed, rate: audioSpeed,
onload: function (this: Howl) { onload: function (this: Howl) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
applyPlaybackRateToHowl(this); applyPlaybackRateToHowl(this);
const estimate = pageTurnEstimateRef.current; const estimate = pageTurnEstimateRef.current;
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
@ -1375,6 +1393,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}, delayMs); }, delayMs);
}, },
onplay: function (this: Howl) { onplay: function (this: Howl) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
setIsProcessing(false); setIsProcessing(false);
startRateWatchdog(this); startRateWatchdog(this);
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
@ -1382,6 +1404,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, },
onpause: function () { onpause: function () {
if (runId !== playbackRunIdRef.current) return;
clearRateWatchdog(); clearRateWatchdog();
playbackInFlightRef.current = false; playbackInFlightRef.current = false;
setIsProcessing(false); setIsProcessing(false);
@ -1395,6 +1418,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, },
onplayerror: function (this: Howl, soundId, error) { onplayerror: function (this: Howl, soundId, error) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
const actualError = error ?? soundId; const actualError = error ?? soundId;
console.warn('Howl playback error:', actualError); console.warn('Howl playback error:', actualError);
@ -1448,6 +1475,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, },
onloaderror: async function (this: Howl, soundId, error) { onloaderror: async function (this: Howl, soundId, error) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
const actualError = error ?? soundId; const actualError = error ?? soundId;
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError);
@ -1462,6 +1493,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
try { try {
const fallbackHowl = await createHowl(retryCount, true); const fallbackHowl = await createHowl(retryCount, true);
if (fallbackHowl) { if (fallbackHowl) {
if (runId !== playbackRunIdRef.current) {
try { fallbackHowl.unload(); } catch {}
return;
}
setActiveHowl(fallbackHowl); setActiveHowl(fallbackHowl);
fallbackHowl.play(); fallbackHowl.play();
return; return;
@ -1490,6 +1525,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
try { try {
const retryHowl = await createHowl(retryCount + 1, useFallbackSource); const retryHowl = await createHowl(retryCount + 1, useFallbackSource);
if (retryHowl) { if (retryHowl) {
if (runId !== playbackRunIdRef.current) {
try { retryHowl.unload(); } catch {}
return;
}
setActiveHowl(retryHowl); setActiveHowl(retryHowl);
retryHowl.play(); retryHowl.play();
} else { } else {
@ -1533,6 +1572,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, },
onend: function (this: Howl) { onend: function (this: Howl) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
if (howlFinished) return; // Deduplicate Firefox can fire ended twice if (howlFinished) return; // Deduplicate Firefox can fire ended twice
howlFinished = true; howlFinished = true;
clearRateWatchdog(); clearRateWatchdog();
@ -1548,6 +1591,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, },
onstop: function (this: Howl) { onstop: function (this: Howl) {
if (runId !== playbackRunIdRef.current) {
try { this.unload(); } catch {}
return;
}
if (howlFinished) return; if (howlFinished) return;
howlFinished = true; howlFinished = true;
clearRateWatchdog(); clearRateWatchdog();
@ -1560,6 +1607,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
try { try {
const howl = await createHowl(); const howl = await createHowl();
if (runId !== playbackRunIdRef.current) {
if (howl) {
try { howl.unload(); } catch {}
}
return null;
}
if (!howl) { if (!howl) {
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without // No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
// advancing or spamming errors. // advancing or spamming errors.
@ -1598,6 +1651,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
]); ]);
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
const runId = playbackRunIdRef.current;
const sentence = sentences[currentIndex]; const sentence = sentences[currentIndex];
const alignmentKey = buildCacheKey( const alignmentKey = buildCacheKey(
sentence, sentence,
@ -1615,7 +1669,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentWordIndex(null); setCurrentWordIndex(null);
} }
const howl = await playSentenceWithHowl(sentence, currentIndex); const howl = await playSentenceWithHowl(sentence, currentIndex, runId);
if (runId !== playbackRunIdRef.current) {
if (howl) {
try { howl.unload(); } catch {}
}
return;
}
if (howl) { if (howl) {
if (!isPlayingRef.current) { if (!isPlayingRef.current) {
playbackInFlightRef.current = false; playbackInFlightRef.current = false;
@ -1794,6 +1854,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/ */
const stop = useCallback(() => { const stop = useCallback(() => {
// Cancel any ongoing request // Cancel any ongoing request
invalidatePlaybackRun();
abortAudio(); abortAudio();
playbackInFlightRef.current = false; playbackInFlightRef.current = false;
locationChangeHandlerRef.current = null; locationChangeHandlerRef.current = null;
@ -1810,7 +1871,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
sentenceAlignmentCacheRef.current.clear(); sentenceAlignmentCacheRef.current.clear();
setCurrentSentenceAlignment(undefined); setCurrentSentenceAlignment(undefined);
setCurrentWordIndex(null); setCurrentWordIndex(null);
}, [abortAudio]); }, [abortAudio, invalidatePlaybackRun]);
/** /**
* Stops the current audio playback and starts playing from a specified index * Stops the current audio playback and starts playing from a specified index
@ -1818,6 +1879,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* @param {number} index - The index to start playing from * @param {number} index - The index to start playing from
*/ */
const stopAndPlayFromIndex = useCallback((index: number) => { const stopAndPlayFromIndex = useCallback((index: number) => {
invalidatePlaybackRun();
abortAudio(); abortAudio();
// Same autoplay-unlock issue as togglePlay when starting from a fresh load. // Same autoplay-unlock issue as togglePlay when starting from a fresh load.
@ -1825,7 +1887,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentIndex(index); setCurrentIndex(index);
setIsPlaying(true); setIsPlaying(true);
}, [abortAudio, unlockPlaybackOnUserGesture]); }, [abortAudio, invalidatePlaybackRun, unlockPlaybackOnUserGesture]);
/** /**
* Sets the speed and restarts the playback * Sets the speed and restarts the playback