From ef2ee090640017ac8e06d29ef0e8699b758e9b1c Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 23:39:29 -0600 Subject: [PATCH] Warm segment audio during preload before playback --- src/contexts/TTSContext.tsx | 51 +++++++++- src/lib/client/tts/audio-warm-cache.ts | 94 ++++++++++++++++++ tests/unit/tts-audio-warm-cache.spec.ts | 127 ++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 src/lib/client/tts/audio-warm-cache.ts create mode 100644 tests/unit/tts-audio-warm-cache.spec.ts diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 6d44824..1397755 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -47,6 +47,11 @@ import { buildWalkerPlanningSourceUnits, selectUpcomingWalkerItems, } from '@/lib/client/epub/tts-epub-preload'; +import { + releaseWarmAudio, + upsertWarmAudioEntry, + type WarmAudioCacheEntry, +} from '@/lib/client/tts/audio-warm-cache'; import { completedEpubBoundarySegment, resolveEpubBoundaryHandoffStartIndex, @@ -198,6 +203,7 @@ type JumpResolution = const LOOP_GUARD_MIN_INDEX = 2; const LOOP_GUARD_MIN_PROGRESS = 0.6; const AUDIO_CACHE_MAX_ITEMS = 25; +const WARM_AUDIO_CACHE_MAX_ITEMS = 6; // Read once per module load from SSR-injected runtime config. This sits at // module scope because the highlight pipeline is constructed lazily and the // flag rarely changes within a session — admin toggling it picks up on @@ -565,6 +571,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Track pending preload requests const preloadRequests = useRef>>(new Map()); + const warmAudioCacheRef = useRef>(new Map()); // Track active abort controllers for TTS requests const activeAbortControllers = useRef>(new Set()); // Synchronous guard to prevent duplicate playAudio calls from the main playback effect. @@ -617,6 +624,36 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement rateWatchdogIntervalRef.current = null; }, []); + const clearWarmAudioCache = useCallback(() => { + warmAudioCacheRef.current.forEach((entry) => { + releaseWarmAudio(entry.audio); + }); + warmAudioCacheRef.current.clear(); + }, []); + + const warmSegmentAudioUrl = useCallback((requestKey: string, primaryUrl: string | null, fallbackUrl: string | null) => { + const candidateUrl = primaryUrl || fallbackUrl; + if (!candidateUrl) return; + if (typeof window === 'undefined' || typeof Audio === 'undefined') return; + + upsertWarmAudioEntry({ + key: requestKey, + url: candidateUrl, + cache: warmAudioCacheRef.current, + maxEntries: WARM_AUDIO_CACHE_MAX_ITEMS, + createAudio: (url) => { + const audio = new Audio(url); + audio.preload = 'auto'; + audio.load(); + return audio; + }, + }); + }, []); + + useEffect(() => () => { + clearWarmAudioCache(); + }, [clearWarmAudioCache]); + const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => { if (!howl) return; @@ -801,6 +838,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }); activeAbortControllers.current.clear(); preloadRequests.current.clear(); + clearWarmAudioCache(); } if (pageTurnTimeoutRef.current) { @@ -808,7 +846,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pageTurnTimeoutRef.current = null; } setCurrentWordIndex(null); - }, [activeHowl, clearRateWatchdog, invalidatePlaybackRun]); + }, [activeHowl, clearRateWatchdog, clearWarmAudioCache, invalidatePlaybackRun]); /** * Pauses the current audio playback while preserving seek position. @@ -1653,6 +1691,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const processPromise = (async () => { try { const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey); + if (preload && source) { + warmSegmentAudioUrl(requestKey, source.presignUrl, source.fallbackUrl); + } return source || null; } catch (error) { setIsProcessing(false); @@ -1674,7 +1715,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } return processPromise; - }, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB]); + }, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB, warmSegmentAudioUrl]); /** * Plays the current sentence with Howl @@ -2386,6 +2427,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) { return null; } + warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl); return { presignUrl: manifest.audioPresignUrl, fallbackUrl: manifest.audioFallbackUrl, @@ -2572,6 +2614,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) { return null; } + warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl); return { presignUrl: manifest.audioPresignUrl, fallbackUrl: manifest.audioFallbackUrl, @@ -2642,6 +2685,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement epubHighlightEnabled, epubWordHighlightEnabled, resolveSegmentsForPersist, + warmSegmentAudioUrl, ]); /** @@ -2690,6 +2734,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Cancel any ongoing request invalidatePlaybackRun(); abortAudio(); + clearWarmAudioCache(); playbackInFlightRef.current = false; pendingJumpTargetRef.current = null; clearPendingEpubJump(); @@ -2710,7 +2755,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement sentenceAlignmentCacheRef.current.clear(); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); - }, [abortAudio, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); + }, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); /** * Stops the current audio playback and starts playing from a specified index diff --git a/src/lib/client/tts/audio-warm-cache.ts b/src/lib/client/tts/audio-warm-cache.ts new file mode 100644 index 0000000..e2f42da --- /dev/null +++ b/src/lib/client/tts/audio-warm-cache.ts @@ -0,0 +1,94 @@ +export type WarmAudioLike = { + pause?: () => void; + removeAttribute?: (qualifiedName: string) => void; + load?: () => void; + src?: string; +}; + +export type WarmAudioCacheEntry = { + key: string; + url: string; + audio: WarmAudioLike; + warmedAt: number; +}; + +type UpsertWarmAudioEntryParams = { + key: string; + url: string; + cache: Map; + createAudio: (url: string) => WarmAudioLike; + maxEntries: number; + nowMs?: number; +}; + +export function releaseWarmAudio(audio: WarmAudioLike): void { + try { + audio.pause?.(); + } catch { + // Ignore media pause errors during teardown. + } + try { + audio.removeAttribute?.('src'); + } catch { + // Ignore source cleanup errors. + } + try { + if (!audio.removeAttribute && 'src' in audio) { + audio.src = ''; + } + } catch { + // Ignore source cleanup errors. + } + try { + audio.load?.(); + } catch { + // Ignore media reload errors during teardown. + } +} + +export function upsertWarmAudioEntry({ + key, + url, + cache, + createAudio, + maxEntries, + nowMs, +}: UpsertWarmAudioEntryParams): WarmAudioCacheEntry { + const now = nowMs ?? Date.now(); + const existing = cache.get(key); + if (existing && existing.url === url) { + existing.warmedAt = now; + return existing; + } + if (existing) { + releaseWarmAudio(existing.audio); + cache.delete(key); + } + + const entry: WarmAudioCacheEntry = { + key, + url, + audio: createAudio(url), + warmedAt: now, + }; + cache.set(key, entry); + + while (cache.size > maxEntries) { + let oldestKey: string | null = null; + let oldestTime = Number.POSITIVE_INFINITY; + for (const [candidateKey, candidate] of cache.entries()) { + if (candidate.warmedAt < oldestTime) { + oldestTime = candidate.warmedAt; + oldestKey = candidateKey; + } + } + if (!oldestKey) break; + const oldest = cache.get(oldestKey); + if (oldest) { + releaseWarmAudio(oldest.audio); + cache.delete(oldestKey); + } + } + + return entry; +} diff --git a/tests/unit/tts-audio-warm-cache.spec.ts b/tests/unit/tts-audio-warm-cache.spec.ts new file mode 100644 index 0000000..06a5d24 --- /dev/null +++ b/tests/unit/tts-audio-warm-cache.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test'; +import { + releaseWarmAudio, + upsertWarmAudioEntry, + type WarmAudioCacheEntry, + type WarmAudioLike, +} from '../../src/lib/client/tts/audio-warm-cache'; + +function createTrackedAudio() { + const calls = { + pause: 0, + removeAttribute: 0, + load: 0, + }; + const audio: WarmAudioLike = { + pause: () => { calls.pause += 1; }, + removeAttribute: (name: string) => { + if (name === 'src') calls.removeAttribute += 1; + }, + load: () => { calls.load += 1; }, + src: 'https://audio.example/current.mp3', + }; + return { audio, calls }; +} + +test.describe('tts warm audio cache helpers', () => { + test('releaseWarmAudio clears source and invokes teardown methods', () => { + const tracked = createTrackedAudio(); + releaseWarmAudio(tracked.audio); + expect(tracked.calls.pause).toBe(1); + expect(tracked.calls.removeAttribute).toBe(1); + expect(tracked.calls.load).toBe(1); + }); + + test('upsertWarmAudioEntry keeps existing entry when key and url match', () => { + const cache = new Map(); + const firstAudio = createTrackedAudio(); + const first = upsertWarmAudioEntry({ + key: 'a', + url: 'https://audio.example/a.mp3', + cache, + maxEntries: 3, + createAudio: () => firstAudio.audio, + nowMs: 100, + }); + const second = upsertWarmAudioEntry({ + key: 'a', + url: 'https://audio.example/a.mp3', + cache, + maxEntries: 3, + createAudio: () => createTrackedAudio().audio, + nowMs: 200, + }); + + expect(second).toBe(first); + expect(second.warmedAt).toBe(200); + expect(cache.size).toBe(1); + }); + + test('upsertWarmAudioEntry replaces changed url and releases prior audio', () => { + const cache = new Map(); + const oldAudio = createTrackedAudio(); + const nextAudio = createTrackedAudio(); + + upsertWarmAudioEntry({ + key: 'a', + url: 'https://audio.example/a-1.mp3', + cache, + maxEntries: 3, + createAudio: () => oldAudio.audio, + nowMs: 100, + }); + + upsertWarmAudioEntry({ + key: 'a', + url: 'https://audio.example/a-2.mp3', + cache, + maxEntries: 3, + createAudio: () => nextAudio.audio, + nowMs: 120, + }); + + expect(oldAudio.calls.pause).toBe(1); + expect(oldAudio.calls.removeAttribute).toBe(1); + expect(oldAudio.calls.load).toBe(1); + expect(cache.get('a')?.url).toBe('https://audio.example/a-2.mp3'); + }); + + test('upsertWarmAudioEntry evicts least-recently-warmed entry when cache is full', () => { + const cache = new Map(); + const a = createTrackedAudio(); + const b = createTrackedAudio(); + const c = createTrackedAudio(); + + upsertWarmAudioEntry({ + key: 'a', + url: 'https://audio.example/a.mp3', + cache, + maxEntries: 2, + createAudio: () => a.audio, + nowMs: 100, + }); + upsertWarmAudioEntry({ + key: 'b', + url: 'https://audio.example/b.mp3', + cache, + maxEntries: 2, + createAudio: () => b.audio, + nowMs: 200, + }); + upsertWarmAudioEntry({ + key: 'c', + url: 'https://audio.example/c.mp3', + cache, + maxEntries: 2, + createAudio: () => c.audio, + nowMs: 300, + }); + + expect(cache.has('a')).toBeFalsy(); + expect(cache.has('b')).toBeTruthy(); + expect(cache.has('c')).toBeTruthy(); + expect(a.calls.pause).toBe(1); + expect(a.calls.removeAttribute).toBe(1); + expect(a.calls.load).toBe(1); + }); +});