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:
parent
773778ee90
commit
7046666b3f
1 changed files with 63 additions and 17 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue