feat: Implement sequential lookahead preloading for multiple upcoming TTS sentences.

This commit is contained in:
Richard R 2026-02-22 03:31:03 -07:00
parent 6e442e8dba
commit 49c5b794ea

View file

@ -1519,45 +1519,73 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}, [activeHowl, isPlaying, currentSentenceAlignment]); }, [activeHowl, isPlaying, currentSentenceAlignment]);
/** /**
* Preloads the next sentence's audio * Preloads upcoming sentences sequentially.
* As soon as one preload finishes, the next one starts (up to lookahead window).
*/ */
const preloadNextAudio = useCallback(async () => { const preloadNextAudio = useCallback(() => {
const PRELOAD_LOOKAHEAD = 3;
if (isAtLimit) return; if (isAtLimit) return;
try {
const nextSentence = sentences[currentIndex + 1];
if (nextSentence) {
const nextKey = buildCacheKey(
nextSentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) { const preloadFromOffset = (offset: number) => {
// Start preloading but don't wait for it to complete if (offset > PRELOAD_LOOKAHEAD) return;
processSentence(nextSentence, true).catch(error => {
const status = (() => { const sentenceIndex = currentIndex + offset;
if (typeof error === 'object' && error !== null && 'status' in error) { const nextSentence = sentences[sentenceIndex];
const maybe = (error as { status?: unknown }).status; if (!nextSentence) return;
return typeof maybe === 'number' ? maybe : undefined;
} const nextKey = buildCacheKey(
return undefined; nextSentence,
})(); voice,
const code = (() => { speed,
if (typeof error === 'object' && error !== null && 'code' in error) { configTTSProvider,
const maybe = (error as { code?: unknown }).code; ttsModel,
return typeof maybe === 'string' ? maybe : undefined; );
}
return undefined; if (audioCache.has(nextKey)) {
})(); preloadFromOffset(offset + 1);
// Ignore quota errors during preload return;
if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
console.error('Error preloading next sentence:', error);
}
});
}
} }
const pending = preloadRequests.current.get(nextSentence);
if (pending) {
void pending
.finally(() => {
preloadFromOffset(offset + 1);
})
.catch(() => {
// Prevent unhandled rejections from the chained preload progression.
});
return;
}
void processSentence(nextSentence, true)
.catch((error) => {
const status = (() => {
if (typeof error === 'object' && error !== null && 'status' in error) {
const maybe = (error as { status?: unknown }).status;
return typeof maybe === 'number' ? maybe : undefined;
}
return undefined;
})();
const code = (() => {
if (typeof error === 'object' && error !== null && 'code' in error) {
const maybe = (error as { code?: unknown }).code;
return typeof maybe === 'string' ? maybe : undefined;
}
return undefined;
})();
// Ignore quota errors during preload.
if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
console.error(`Error preloading sentence at offset ${offset}:`, error);
}
})
.finally(() => {
preloadFromOffset(offset + 1);
});
};
try {
preloadFromOffset(1);
} catch (error) { } catch (error) {
console.error('Error initiating preload:', error); console.error('Error initiating preload:', error);
} }
@ -1576,7 +1604,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Start playing current sentence // Start playing current sentence
playAudio(); playAudio();
// Start preloading next sentence in parallel // Start background lookahead preloading for upcoming sentences.
preloadNextAudio(); preloadNextAudio();
return () => { return () => {