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,
|
buildWalkerPlanningSourceUnits,
|
||||||
selectUpcomingWalkerItems,
|
selectUpcomingWalkerItems,
|
||||||
} from '@/lib/client/epub/tts-epub-preload';
|
} from '@/lib/client/epub/tts-epub-preload';
|
||||||
|
import {
|
||||||
|
releaseWarmAudio,
|
||||||
|
upsertWarmAudioEntry,
|
||||||
|
type WarmAudioCacheEntry,
|
||||||
|
} from '@/lib/client/tts/audio-warm-cache';
|
||||||
import {
|
import {
|
||||||
completedEpubBoundarySegment,
|
completedEpubBoundarySegment,
|
||||||
resolveEpubBoundaryHandoffStartIndex,
|
resolveEpubBoundaryHandoffStartIndex,
|
||||||
|
|
@ -198,6 +203,7 @@ type JumpResolution =
|
||||||
const LOOP_GUARD_MIN_INDEX = 2;
|
const LOOP_GUARD_MIN_INDEX = 2;
|
||||||
const LOOP_GUARD_MIN_PROGRESS = 0.6;
|
const LOOP_GUARD_MIN_PROGRESS = 0.6;
|
||||||
const AUDIO_CACHE_MAX_ITEMS = 25;
|
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
|
// Read once per module load from SSR-injected runtime config. This sits at
|
||||||
// module scope because the highlight pipeline is constructed lazily and the
|
// module scope because the highlight pipeline is constructed lazily and the
|
||||||
// flag rarely changes within a session — admin toggling it picks up on
|
// 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
|
// Track pending preload requests
|
||||||
const preloadRequests = useRef<Map<string, Promise<TTSSegmentPlaybackSource | null>>>(new Map());
|
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
|
// Track active abort controllers for TTS requests
|
||||||
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
||||||
// Synchronous guard to prevent duplicate playAudio calls from the main playback effect.
|
// 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;
|
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) => {
|
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
||||||
if (!howl) return;
|
if (!howl) return;
|
||||||
|
|
||||||
|
|
@ -801,6 +838,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
});
|
});
|
||||||
activeAbortControllers.current.clear();
|
activeAbortControllers.current.clear();
|
||||||
preloadRequests.current.clear();
|
preloadRequests.current.clear();
|
||||||
|
clearWarmAudioCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageTurnTimeoutRef.current) {
|
if (pageTurnTimeoutRef.current) {
|
||||||
|
|
@ -808,7 +846,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
pageTurnTimeoutRef.current = null;
|
pageTurnTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
setCurrentWordIndex(null);
|
setCurrentWordIndex(null);
|
||||||
}, [activeHowl, clearRateWatchdog, invalidatePlaybackRun]);
|
}, [activeHowl, clearRateWatchdog, clearWarmAudioCache, invalidatePlaybackRun]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pauses the current audio playback while preserving seek position.
|
* Pauses the current audio playback while preserving seek position.
|
||||||
|
|
@ -1653,6 +1691,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const processPromise = (async () => {
|
const processPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey);
|
const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey);
|
||||||
|
if (preload && source) {
|
||||||
|
warmSegmentAudioUrl(requestKey, source.presignUrl, source.fallbackUrl);
|
||||||
|
}
|
||||||
return source || null;
|
return source || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
@ -1674,7 +1715,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
return processPromise;
|
return processPromise;
|
||||||
}, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB]);
|
}, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB, warmSegmentAudioUrl]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays the current sentence with Howl
|
* 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) {
|
if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl);
|
||||||
return {
|
return {
|
||||||
presignUrl: manifest.audioPresignUrl,
|
presignUrl: manifest.audioPresignUrl,
|
||||||
fallbackUrl: manifest.audioFallbackUrl,
|
fallbackUrl: manifest.audioFallbackUrl,
|
||||||
|
|
@ -2572,6 +2614,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) {
|
if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl);
|
||||||
return {
|
return {
|
||||||
presignUrl: manifest.audioPresignUrl,
|
presignUrl: manifest.audioPresignUrl,
|
||||||
fallbackUrl: manifest.audioFallbackUrl,
|
fallbackUrl: manifest.audioFallbackUrl,
|
||||||
|
|
@ -2642,6 +2685,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
resolveSegmentsForPersist,
|
resolveSegmentsForPersist,
|
||||||
|
warmSegmentAudioUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2690,6 +2734,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
invalidatePlaybackRun();
|
invalidatePlaybackRun();
|
||||||
abortAudio();
|
abortAudio();
|
||||||
|
clearWarmAudioCache();
|
||||||
playbackInFlightRef.current = false;
|
playbackInFlightRef.current = false;
|
||||||
pendingJumpTargetRef.current = null;
|
pendingJumpTargetRef.current = null;
|
||||||
clearPendingEpubJump();
|
clearPendingEpubJump();
|
||||||
|
|
@ -2710,7 +2755,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentenceAlignmentCacheRef.current.clear();
|
sentenceAlignmentCacheRef.current.clear();
|
||||||
setCurrentSentenceAlignment(undefined);
|
setCurrentSentenceAlignment(undefined);
|
||||||
setCurrentWordIndex(null);
|
setCurrentWordIndex(null);
|
||||||
}, [abortAudio, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]);
|
}, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the current audio playback and starts playing from a specified index
|
* 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