听书音频缓存,进度缓存
This commit is contained in:
@@ -15,6 +15,13 @@ import {
|
|||||||
touchCachedBookFile,
|
touchCachedBookFile,
|
||||||
} from '@/lib/book-cache.client';
|
} from '@/lib/book-cache.client';
|
||||||
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||||
|
import {
|
||||||
|
buildBookTtsCacheKey,
|
||||||
|
enforceBookTtsCacheLimit,
|
||||||
|
getCachedBookTtsChunk,
|
||||||
|
putCachedBookTtsChunk,
|
||||||
|
touchCachedBookTtsChunk,
|
||||||
|
} from '@/lib/book-tts-cache.client';
|
||||||
import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client';
|
import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -273,6 +280,13 @@ function formatBytes(size: number): string {
|
|||||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDurationTime(value: number) {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(value || 0));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeTtsText(text: string): string {
|
function sanitizeTtsText(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/\u00a0/g, ' ')
|
.replace(/\u00a0/g, ' ')
|
||||||
@@ -436,6 +450,7 @@ export default function BookReadPage() {
|
|||||||
const ttsSeekingRef = useRef(false);
|
const ttsSeekingRef = useRef(false);
|
||||||
const ttsPrefetchedFromChunkRef = useRef<number | null>(null);
|
const ttsPrefetchedFromChunkRef = useRef<number | null>(null);
|
||||||
const ttsPrefetchFnRef = useRef<(fromIndex: number) => void>(() => undefined);
|
const ttsPrefetchFnRef = useRef<(fromIndex: number) => void>(() => undefined);
|
||||||
|
const ttsResumeTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettings(loadReaderSettings());
|
setSettings(loadReaderSettings());
|
||||||
@@ -715,6 +730,7 @@ export default function BookReadPage() {
|
|||||||
chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter,
|
chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter,
|
||||||
chunkIndex: currentIndex,
|
chunkIndex: currentIndex,
|
||||||
charOffset: chunk.start,
|
charOffset: chunk.start,
|
||||||
|
currentTimeSec: audioRef.current?.currentTime || 0,
|
||||||
voice: ttsSettingsRef.current.voice,
|
voice: ttsSettingsRef.current.voice,
|
||||||
rate: ttsSettingsRef.current.rate,
|
rate: ttsSettingsRef.current.rate,
|
||||||
pitch: ttsSettingsRef.current.pitch,
|
pitch: ttsSettingsRef.current.pitch,
|
||||||
@@ -735,6 +751,27 @@ export default function BookReadPage() {
|
|||||||
const cached = ttsChunkBlobCacheRef.current[chunk.index];
|
const cached = ttsChunkBlobCacheRef.current[chunk.index];
|
||||||
if (cached?.text === chunk.text) return cached.url;
|
if (cached?.text === chunk.text) return cached.url;
|
||||||
if (!manifest) throw new Error('书籍信息未准备好');
|
if (!manifest) throw new Error('书籍信息未准备好');
|
||||||
|
const { cacheKey, textHash } = await buildBookTtsCacheKey({
|
||||||
|
sourceId: manifest.book.sourceId,
|
||||||
|
bookId: manifest.book.id,
|
||||||
|
chapterHref,
|
||||||
|
chunkIndex: chunk.index,
|
||||||
|
text: chunk.text,
|
||||||
|
voice: ttsSettingsRef.current.voice,
|
||||||
|
rate: ttsSettingsRef.current.rate,
|
||||||
|
pitch: ttsSettingsRef.current.pitch,
|
||||||
|
volume: ttsSettingsRef.current.volume,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await getCachedBookTtsChunk(cacheKey).catch(() => null);
|
||||||
|
if (persisted?.audioBlob) {
|
||||||
|
const url = URL.createObjectURL(persisted.audioBlob);
|
||||||
|
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
|
||||||
|
ttsChunkAudioUrlRef.current[chunk.index] = url;
|
||||||
|
void touchCachedBookTtsChunk(cacheKey).catch(() => undefined);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/books/tts/synthesize', {
|
const response = await fetch('/api/books/tts/synthesize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -756,6 +793,26 @@ export default function BookReadPage() {
|
|||||||
if (cached?.url) URL.revokeObjectURL(cached.url);
|
if (cached?.url) URL.revokeObjectURL(cached.url);
|
||||||
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
|
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
|
||||||
ttsChunkAudioUrlRef.current[chunk.index] = url;
|
ttsChunkAudioUrlRef.current[chunk.index] = url;
|
||||||
|
void putCachedBookTtsChunk({
|
||||||
|
cacheKey,
|
||||||
|
sourceId: manifest.book.sourceId,
|
||||||
|
bookId: manifest.book.id,
|
||||||
|
chapterHref,
|
||||||
|
chunkIndex: chunk.index,
|
||||||
|
textHash,
|
||||||
|
voice: ttsSettingsRef.current.voice,
|
||||||
|
rate: ttsSettingsRef.current.rate,
|
||||||
|
pitch: ttsSettingsRef.current.pitch,
|
||||||
|
volume: ttsSettingsRef.current.volume,
|
||||||
|
textPreview: chunk.text.slice(0, 80),
|
||||||
|
mimeType: json.mimeType || 'audio/mpeg',
|
||||||
|
audioBlob: blob,
|
||||||
|
size: blob.size,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastAccessAt: Date.now(),
|
||||||
|
})
|
||||||
|
.then(() => enforceBookTtsCacheLimit())
|
||||||
|
.catch(() => undefined);
|
||||||
return url;
|
return url;
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
|
|
||||||
@@ -778,7 +835,7 @@ export default function BookReadPage() {
|
|||||||
const chunks = ttsChunksRef.current;
|
const chunks = ttsChunksRef.current;
|
||||||
const chunk = chunks[index];
|
const chunk = chunks[index];
|
||||||
const chapterHref = ttsCurrentChapterHrefRef.current;
|
const chapterHref = ttsCurrentChapterHrefRef.current;
|
||||||
if (!chunk || !chapterHref) return;
|
if (!chunk || !chapterHref || !manifest) return;
|
||||||
try {
|
try {
|
||||||
setTtsError('');
|
setTtsError('');
|
||||||
setTtsLoadingChunkIndex(index);
|
setTtsLoadingChunkIndex(index);
|
||||||
@@ -788,6 +845,11 @@ export default function BookReadPage() {
|
|||||||
audioRef.current = new Audio();
|
audioRef.current = new Audio();
|
||||||
}
|
}
|
||||||
audioRef.current.src = url;
|
audioRef.current.src = url;
|
||||||
|
ttsResumeTimeRef.current = 0;
|
||||||
|
const saved = getBookTtsProgress(manifest.book.sourceId, manifest.book.id);
|
||||||
|
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
|
||||||
|
ttsResumeTimeRef.current = saved.currentTimeSec || 0;
|
||||||
|
}
|
||||||
await audioRef.current.play();
|
await audioRef.current.play();
|
||||||
ttsCurrentChunkIndexRef.current = index;
|
ttsCurrentChunkIndexRef.current = index;
|
||||||
setTtsCurrentChunkIndex(index);
|
setTtsCurrentChunkIndex(index);
|
||||||
@@ -799,7 +861,7 @@ export default function BookReadPage() {
|
|||||||
setTtsLoadingChunkIndex(null);
|
setTtsLoadingChunkIndex(null);
|
||||||
setTtsError((error as Error).message || '朗读失败');
|
setTtsError((error as Error).message || '朗读失败');
|
||||||
}
|
}
|
||||||
}, [fetchTtsChunkAudioUrl, persistTtsProgress]);
|
}, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]);
|
||||||
|
|
||||||
const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => {
|
const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => {
|
||||||
if (!manifest || manifest.format !== 'epub') return;
|
if (!manifest || manifest.format !== 'epub') return;
|
||||||
@@ -1076,6 +1138,10 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
const nextDuration = audio.duration || 0;
|
const nextDuration = audio.duration || 0;
|
||||||
|
if (ttsResumeTimeRef.current > 0 && nextDuration > 0) {
|
||||||
|
audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, nextDuration - 0.25));
|
||||||
|
ttsResumeTimeRef.current = 0;
|
||||||
|
}
|
||||||
setTtsDuration(nextDuration);
|
setTtsDuration(nextDuration);
|
||||||
if (!ttsSeekingRef.current) {
|
if (!ttsSeekingRef.current) {
|
||||||
setTtsSeekValue(audio.currentTime || 0);
|
setTtsSeekValue(audio.currentTime || 0);
|
||||||
@@ -1433,7 +1499,7 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
|
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
|
||||||
<span>{selectedVoice?.displayName || '默认音色'}</span>
|
<span>{selectedVoice?.displayName || '默认音色'}</span>
|
||||||
<span>{Math.floor(displayedTtsTime)}s / {Math.floor(ttsDuration || 0)}s</span>
|
<span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
const DB_NAME = 'moontv_book_tts';
|
||||||
|
const STORE_NAME = 'audio_chunks';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const DEFAULT_CACHE_LIMIT = 150 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface CachedBookTtsChunk {
|
||||||
|
cacheKey: string;
|
||||||
|
sourceId: string;
|
||||||
|
bookId: string;
|
||||||
|
chapterHref: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
textHash: string;
|
||||||
|
voice: string;
|
||||||
|
rate: string;
|
||||||
|
pitch: string;
|
||||||
|
volume: string;
|
||||||
|
textPreview: string;
|
||||||
|
mimeType: string;
|
||||||
|
audioBlob: Blob;
|
||||||
|
size: number;
|
||||||
|
duration?: number;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' });
|
||||||
|
store.createIndex('lastAccessAt', 'lastAccessAt', { unique: false });
|
||||||
|
store.createIndex('book', ['sourceId', 'bookId'], { unique: false });
|
||||||
|
store.createIndex('chapter', ['sourceId', 'bookId', 'chapterHref'], { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error || new Error('打开听书缓存失败'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(input: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((item) => item.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBookTtsCacheKey(input: {
|
||||||
|
sourceId: string;
|
||||||
|
bookId: string;
|
||||||
|
chapterHref: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
text: string;
|
||||||
|
voice: string;
|
||||||
|
rate: string;
|
||||||
|
pitch: string;
|
||||||
|
volume: string;
|
||||||
|
}): Promise<{ cacheKey: string; textHash: string }> {
|
||||||
|
const textHash = await sha256(input.text);
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
bookId: input.bookId,
|
||||||
|
chapterHref: input.chapterHref,
|
||||||
|
chunkIndex: input.chunkIndex,
|
||||||
|
voice: input.voice,
|
||||||
|
rate: input.rate,
|
||||||
|
pitch: input.pitch,
|
||||||
|
volume: input.volume,
|
||||||
|
textHash,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cacheKey: `bookTts:${await sha256(raw)}`,
|
||||||
|
textHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedBookTtsChunk(cacheKey: string): Promise<CachedBookTtsChunk | null> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const request = tx.objectStore(STORE_NAME).get(cacheKey);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db.close();
|
||||||
|
resolve((request.result as CachedBookTtsChunk | undefined) || null);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(request.error || new Error('读取听书缓存失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCachedBookTtsChunk(record: CachedBookTtsChunk): Promise<void> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).put(record);
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error || new Error('写入听书缓存失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchCachedBookTtsChunk(cacheKey: string): Promise<void> {
|
||||||
|
const current = await getCachedBookTtsChunk(cacheKey);
|
||||||
|
if (!current) return;
|
||||||
|
await putCachedBookTtsChunk({ ...current, lastAccessAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCachedBookTtsChunks(): Promise<CachedBookTtsChunk[]> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const request = tx.objectStore(STORE_NAME).getAll();
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db.close();
|
||||||
|
resolve((request.result as CachedBookTtsChunk[]) || []);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(request.error || new Error('读取听书缓存列表失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCachedBookTtsChunk(cacheKey: string): Promise<void> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).delete(cacheKey);
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error || new Error('删除听书缓存失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceBookTtsCacheLimit(limit = DEFAULT_CACHE_LIMIT): Promise<void> {
|
||||||
|
const items = await listCachedBookTtsChunks();
|
||||||
|
const total = items.reduce((sum, item) => sum + item.size, 0);
|
||||||
|
if (total <= limit) return;
|
||||||
|
let current = total;
|
||||||
|
const sorted = [...items].sort((a, b) => a.lastAccessAt - b.lastAccessAt);
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (current <= limit) break;
|
||||||
|
await deleteCachedBookTtsChunk(item.cacheKey);
|
||||||
|
current -= item.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ export interface BookTtsProgress {
|
|||||||
chapterTitle?: string;
|
chapterTitle?: string;
|
||||||
chunkIndex: number;
|
chunkIndex: number;
|
||||||
charOffset: number;
|
charOffset: number;
|
||||||
|
currentTimeSec?: number;
|
||||||
voice: string;
|
voice: string;
|
||||||
rate: string;
|
rate: string;
|
||||||
pitch: string;
|
pitch: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user