fix(tts): ensure robust caching for audio and alignments

- New buildCacheKey function creates unique identifiers for TTS cache entries.
- Cache keys now include sentence, voice, speed, provider, and model parameters.
- Prevents serving cached audio or alignment data that mismatches current TTS parameters.
- Removes redundant audioCache.clear() calls when voice or speed change, as entries are now distinct.
This commit is contained in:
Richard Roberson 2025-11-22 01:57:27 -07:00
parent 773778ee90
commit 7046666b3f

View file

@ -246,6 +246,22 @@ const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult
}; };
}; };
const buildCacheKey = (
sentence: string,
voice: string,
speed: number,
provider: string,
model: string,
) => {
return [
`provider=${provider || ''}`,
`model=${model || ''}`,
`voice=${voice || ''}`,
`speed=${Number.isFinite(speed) ? speed : ''}`,
`text=${sentence}`,
].join('|');
};
// Create the context // Create the context
const TTSContext = createContext<TTSContextType | undefined>(undefined); const TTSContext = createContext<TTSContextType | undefined>(undefined);
@ -745,7 +761,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// served from the local cache. // served from the local cache.
const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => { const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => {
if (!alignmentEnabledForCurrentDoc) return; if (!alignmentEnabledForCurrentDoc) return;
if (sentenceAlignmentCacheRef.current.has(sentence)) return; const alignmentKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
if (sentenceAlignmentCacheRef.current.has(alignmentKey)) return;
try { try {
const audioBytes = Array.from(new Uint8Array(arrayBuffer)); const audioBytes = Array.from(new Uint8Array(arrayBuffer));
@ -760,7 +783,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return; return;
} }
const alignment = data.alignments[0] as TTSSentenceAlignment; const alignment = data.alignments[0] as TTSSentenceAlignment;
sentenceAlignmentCacheRef.current.set(sentence, alignment); sentenceAlignmentCacheRef.current.set(alignmentKey, alignment);
const currentSentence = sentencesRef.current[currentIndexRef.current]; const currentSentence = sentencesRef.current[currentIndexRef.current];
if (currentSentence === sentence) { if (currentSentence === sentence) {
@ -776,8 +799,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}; };
const audioCacheKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
// Check if the audio is already cached // Check if the audio is already cached
const cachedAudio = audioCache.get(sentence); const cachedAudio = audioCache.get(audioCacheKey);
if (cachedAudio) { if (cachedAudio) {
console.log('Using cached audio for sentence:', sentence.substring(0, 20)); console.log('Using cached audio for sentence:', sentence.substring(0, 20));
// If we have audio but no alignment (e.g. after a // If we have audio but no alignment (e.g. after a
@ -829,7 +860,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.delete(controller); activeAbortControllers.current.delete(controller);
// Cache the array buffer // Cache the array buffer
audioCache.set(sentence, arrayBuffer); audioCache.set(audioCacheKey, arrayBuffer);
// Fire-and-forget alignment request; do not block audio playback // Fire-and-forget alignment request; do not block audio playback
ensureAlignment(arrayBuffer); ensureAlignment(arrayBuffer);
@ -1084,7 +1115,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
const sentence = sentences[currentIndex]; const sentence = sentences[currentIndex];
const cachedAlignment = sentenceAlignmentCacheRef.current.get(sentence); const alignmentKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
if (cachedAlignment) { if (cachedAlignment) {
setCurrentSentenceAlignment(cachedAlignment); setCurrentSentenceAlignment(cachedAlignment);
setCurrentWordIndex(null); setCurrentWordIndex(null);
@ -1097,7 +1135,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (howl) { if (howl) {
howl.play(); howl.play();
} }
}, [sentences, currentIndex, playSentenceWithHowl]); }, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]);
// Place useBackgroundState after playAudio is defined // Place useBackgroundState after playAudio is defined
const isBackgrounded = useBackgroundState({ const isBackgrounded = useBackgroundState({
@ -1153,16 +1191,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadNextAudio = useCallback(async () => { const preloadNextAudio = useCallback(async () => {
try { try {
const nextSentence = sentences[currentIndex + 1]; const nextSentence = sentences[currentIndex + 1];
if (nextSentence && !audioCache.has(nextSentence) && !preloadRequests.current.has(nextSentence)) { if (nextSentence) {
const nextKey = buildCacheKey(
nextSentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) {
// Start preloading but don't wait for it to complete // Start preloading but don't wait for it to complete
processSentence(nextSentence, true).catch(error => { processSentence(nextSentence, true).catch(error => {
console.error('Error preloading next sentence:', error); console.error('Error preloading next sentence:', error);
}); });
}
} }
} catch (error) { } catch (error) {
console.error('Error initiating preload:', error); console.error('Error initiating preload:', error);
} }
}, [currentIndex, sentences, audioCache, processSentence]); }, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
/** /**
* Main Playback Driver * Main Playback Driver
@ -1251,9 +1299,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
abortAudio(true); // Clear pending requests since speed changed abortAudio(true); // Clear pending requests since speed changed
setActiveHowl(null); setActiveHowl(null);
// Update speed, clear cache, and config // Update speed and config
setSpeed(newSpeed); setSpeed(newSpeed);
audioCache.clear();
// Update config after state changes // Update config after state changes
updateConfigKey('voiceSpeed', newSpeed).then(() => { updateConfigKey('voiceSpeed', newSpeed).then(() => {
@ -1263,7 +1310,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsPlaying(true); setIsPlaying(true);
} }
}); });
}, [abortAudio, updateConfigKey, audioCache, isPlaying]); }, [abortAudio, updateConfigKey, isPlaying]);
/** /**
* Sets the voice and restarts the playback * Sets the voice and restarts the playback
@ -1284,9 +1331,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
abortAudio(true); // Clear pending requests since voice changed abortAudio(true); // Clear pending requests since voice changed
setActiveHowl(null); setActiveHowl(null);
// Update voice, clear cache, and config // Update voice and config
setVoice(newVoice); setVoice(newVoice);
audioCache.clear();
// Update config after state changes // Update config after state changes
updateConfigKey('voice', newVoice).then(() => { updateConfigKey('voice', newVoice).then(() => {
@ -1296,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsPlaying(true); setIsPlaying(true);
} }
}); });
}, [abortAudio, updateConfigKey, audioCache, isPlaying]); }, [abortAudio, updateConfigKey, isPlaying]);
/** /**
* Sets the audio player speed and restarts the playback * Sets the audio player speed and restarts the playback