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:
parent
7d7e933323
commit
222d221e81
1 changed files with 74 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue