fix(issue #81): Add synchronous guard to prevent duplicate audio playback in TTSProvider

This commit is contained in:
Richard R 2026-03-18 01:57:01 -06:00
parent cf7cac6ebb
commit fe05de9fec

View file

@ -374,6 +374,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadRequests = useRef<Map<string, Promise<string>>>(new Map());
// Track active abort controllers for TTS requests
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
// Synchronous guard to prevent duplicate playAudio calls from the main playback effect.
// React state updates (isProcessing, activeHowl) are async, so between the effect firing
// 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.
const playbackInFlightRef = useRef(false);
// Track if we're restoring from a saved position
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
// Guard to coalesce rapid restarts and only resume the latest change
@ -1217,6 +1222,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => {
if (!sentence) {
console.log('No sentence to play');
playbackInFlightRef.current = false;
setIsProcessing(false);
return;
}
@ -1239,6 +1245,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeHowl.unload();
}
// Guard against Firefox firing onend/onstop multiple times for the same Audio element.
let howlFinished = false;
return new Howl({
src: [audioDataUri],
format: ['mp3', 'mpeg'],
@ -1285,6 +1294,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Common on iOS/Safari when the actual play() call happens after awaiting TTS.
// Do not skip/advance in this case; just pause and tell the user to tap play again.
if (isAutoplayBlockedError(actualError)) {
howlFinished = true;
playbackInFlightRef.current = false;
setIsProcessing(false);
setActiveHowl(null);
try {
@ -1306,6 +1317,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Avoid looping for many seconds on Safari: if playback still fails after a single
// recovery attempt, skip the sentence and pause.
if (playErrorAttempts > 1) {
howlFinished = true;
playbackInFlightRef.current = false;
setIsProcessing(false);
setActiveHowl(null);
this.unload();
@ -1354,12 +1367,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
retryHowl.play();
} else {
// No audio generated (quota/abort). Stop cleanly without spamming errors.
howlFinished = true;
playbackInFlightRef.current = false;
setIsProcessing(false);
setActiveHowl(null);
setIsPlaying(false);
}
} catch (err) {
console.error('Error creating Howl instance:', err);
howlFinished = true;
playbackInFlightRef.current = false;
setIsProcessing(false);
setActiveHowl(null);
setIsPlaying(false);
@ -1373,6 +1390,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
} else {
console.error('Max retries reached, moving to next sentence');
howlFinished = true;
playbackInFlightRef.current = false;
setIsProcessing(false);
setActiveHowl(null);
this.unload();
@ -1387,8 +1406,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
},
onend: function (this: Howl) {
if (howlFinished) return; // Deduplicate Firefox can fire ended twice
howlFinished = true;
clearRateWatchdog();
this.unload();
playbackInFlightRef.current = false;
setActiveHowl(null);
if (pageTurnTimeoutRef.current) {
clearTimeout(pageTurnTimeoutRef.current);
@ -1399,7 +1421,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
},
onstop: function (this: Howl) {
if (howlFinished) return;
howlFinished = true;
clearRateWatchdog();
playbackInFlightRef.current = false;
setIsProcessing(false);
this.unload();
}
@ -1411,6 +1436,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (!howl) {
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
// advancing or spamming errors.
playbackInFlightRef.current = false;
setActiveHowl(null);
setIsProcessing(false);
setIsPlaying(false);
@ -1421,6 +1447,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return howl;
} catch (error) {
console.error('Error playing TTS:', error);
playbackInFlightRef.current = false;
setActiveHowl(null);
setIsProcessing(false);
@ -1596,10 +1623,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* Controls the flow of audio playback and sentence processing
*/
useEffect(() => {
if (!isPlaying) return; // Don't proceed if stopped
if (isProcessing) return; // Don't proceed if processing audio
if (!isPlaying) {
playbackInFlightRef.current = false;
return;
}
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
if (activeHowl) return; // Don't proceed if audio is already playing
// Single synchronous guard: covers the entire window from when playAudio() is
// called until the Howl finishes (onend/onstop/error). React state guards
// (isProcessing, activeHowl) are async and leave a micro-task gap that allows
// the effect to re-fire and start duplicate playback — especially in Firefox
// where HTML5 Audio events can trigger extra renders.
if (playbackInFlightRef.current) return;
playbackInFlightRef.current = true;
// Start playing current sentence
playAudio();
@ -1615,10 +1650,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
};
}, [
isPlaying,
isProcessing,
currentIndex,
sentences,
activeHowl,
playAudio,
preloadNextAudio,
abortAudio
@ -1630,6 +1663,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const stop = useCallback(() => {
// Cancel any ongoing request
abortAudio();
playbackInFlightRef.current = false;
locationChangeHandlerRef.current = null;
epubContinuationRef.current = null;
continuationCarryRef.current.clear();