Warm segment audio during preload before playback
This commit is contained in:
parent
16bf52f889
commit
ef2ee09064
3 changed files with 269 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
94
src/lib/client/tts/audio-warm-cache.ts
Normal file
94
src/lib/client/tts/audio-warm-cache.ts
Normal 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;
|
||||
}
|
||||
127
tests/unit/tts-audio-warm-cache.spec.ts
Normal file
127
tests/unit/tts-audio-warm-cache.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue