Warm segment audio during preload before playback

This commit is contained in:
Richard R 2026-05-13 23:39:29 -06:00
parent 16bf52f889
commit ef2ee09064
3 changed files with 269 additions and 3 deletions

View file

@ -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<Map<string, Promise<TTSSegmentPlaybackSource | null>>>(new Map());
const warmAudioCacheRef = useRef<Map<string, WarmAudioCacheEntry>>(new Map());
// Track active abort controllers for TTS requests
const activeAbortControllers = useRef<Set<AbortController>>(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

View file

@ -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<string, WarmAudioCacheEntry>;
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;
}

View file

@ -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<string, WarmAudioCacheEntry>();
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<string, WarmAudioCacheEntry>();
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<string, WarmAudioCacheEntry>();
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);
});
});