电子书馆增加听书功能

This commit is contained in:
mtvpls
2026-05-04 14:50:28 +08:00
parent 038abff091
commit da7b01bc35
11 changed files with 1290 additions and 54 deletions
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { BookTtsProgress } from './book.types';
import { generateStorageKey } from './db.client';
const BOOK_TTS_PROGRESS_KEY = 'moontv_book_tts_progress';
function readAll(): Record<string, BookTtsProgress> {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(BOOK_TTS_PROGRESS_KEY);
return raw ? JSON.parse(raw) as Record<string, BookTtsProgress> : {};
} catch {
return {};
}
}
function writeAll(data: Record<string, BookTtsProgress>) {
if (typeof window === 'undefined') return;
localStorage.setItem(BOOK_TTS_PROGRESS_KEY, JSON.stringify(data));
}
export function getBookTtsProgress(sourceId: string, bookId: string): BookTtsProgress | null {
return readAll()[generateStorageKey(sourceId, bookId)] || null;
}
export function saveBookTtsProgress(progress: BookTtsProgress) {
const data = readAll();
data[generateStorageKey(progress.sourceId, progress.bookId)] = progress;
writeAll(data);
}
export function deleteBookTtsProgress(sourceId: string, bookId: string) {
const data = readAll();
delete data[generateStorageKey(sourceId, bookId)];
writeAll(data);
}
+227
View File
@@ -0,0 +1,227 @@
import { createHash } from 'crypto';
import { BookTtsBoundary, BookTtsVoice } from './book.types';
import { getConfig } from './config';
type RawVoice = {
Name?: string;
ShortName?: string;
Locale?: string;
Gender?: string;
FriendlyName?: string;
DisplayName?: string;
};
type RawBoundary = {
offset?: number;
duration?: number;
text?: string;
word?: string;
};
type TtsSynthesizeResult = {
audioBuffer: Buffer;
mimeType: string;
boundaries: BookTtsBoundary[];
};
type EdgeTtsSynthesizer = {
synthesize: () => Promise<{
audio?: unknown;
mimeType?: string;
subtitle?: RawBoundary[] | { words?: RawBoundary[] };
subtitles?: RawBoundary[];
}>;
};
type EdgeTtsVoicesManagerInstance = {
getVoices?: () => Promise<RawVoice[]>;
voices?: RawVoice[];
};
type EdgeTtsConstructor = new (
text: string,
voice: string,
options?: {
rate?: string;
pitch?: string;
volume?: string;
}
) => EdgeTtsSynthesizer;
export type BookTtsRuntimeConfig = {
enabled: boolean;
provider: 'edge-tts-universal';
defaultVoice: string;
defaultRate: string;
defaultPitch: string;
defaultVolume: string;
maxCharsPerChunk: number;
prefetchChunks: number;
cacheEnabled: boolean;
cacheTtl: number;
maxTextLengthPerRequest: number;
};
type EdgeTtsModule = {
EdgeTTS?: EdgeTtsConstructor;
VoicesManager?: {
create?: () => Promise<EdgeTtsVoicesManagerInstance>;
new (): EdgeTtsVoicesManagerInstance;
};
default?: {
EdgeTTS?: EdgeTtsConstructor;
VoicesManager?: {
create?: () => Promise<EdgeTtsVoicesManagerInstance>;
new (): EdgeTtsVoicesManagerInstance;
};
};
};
function resolveEdgeTtsModule(): EdgeTtsModule {
try {
// eslint-disable-next-line no-eval
return eval('require')('edge-tts-universal') as EdgeTtsModule;
} catch (error) {
throw new Error(`未安装 edge-tts-universal,请先执行 pnpm add edge-tts-universal。${(error as Error).message}`);
}
}
function normalizeVoice(item: RawVoice): BookTtsVoice {
const shortName = item.ShortName || item.Name || '';
const displayName = item.DisplayName || item.FriendlyName || shortName;
return {
name: item.Name || shortName,
shortName,
locale: item.Locale || '',
gender: item.Gender || undefined,
displayName,
};
}
function normalizeBoundary(item: RawBoundary): BookTtsBoundary {
return {
offset: Number(item.offset || 0),
duration: Number(item.duration || 0),
text: item.text || item.word || '',
};
}
function isArrayBufferLike(audio: unknown): audio is { arrayBuffer: () => Promise<ArrayBuffer> } {
return !!audio && typeof audio === 'object' && typeof (audio as { arrayBuffer?: unknown }).arrayBuffer === 'function';
}
async function toBuffer(audio: unknown): Promise<Buffer> {
if (Buffer.isBuffer(audio)) return audio;
if (audio instanceof Uint8Array) return Buffer.from(audio);
if (audio instanceof ArrayBuffer) return Buffer.from(audio);
if (isArrayBufferLike(audio)) return Buffer.from(await audio.arrayBuffer());
if (typeof audio === 'string') return Buffer.from(audio, 'base64');
throw new Error('无法识别 edge-tts-universal 返回的音频格式');
}
function buildCacheKey(input: {
sourceId: string;
bookId: string;
chapterHref: string;
voice: string;
rate: string;
pitch: string;
volume: string;
text: string;
}) {
return createHash('sha256').update(JSON.stringify(input)).digest('hex');
}
export async function getBookTtsConfig(): Promise<BookTtsRuntimeConfig> {
await getConfig();
return {
enabled: true,
provider: 'edge-tts-universal',
defaultVoice: 'zh-CN-XiaoxiaoNeural',
defaultRate: '+0%',
defaultPitch: '+0Hz',
defaultVolume: '+0%',
maxCharsPerChunk: 1200,
prefetchChunks: 1,
cacheEnabled: false,
cacheTtl: 0,
maxTextLengthPerRequest: 2000,
};
}
export async function listBookTtsVoices(): Promise<BookTtsVoice[]> {
const mod = resolveEdgeTtsModule();
const VoicesManager = mod.VoicesManager || mod.default?.VoicesManager;
if (!VoicesManager) throw new Error('edge-tts-universal 未导出 VoicesManager');
let voices: RawVoice[] = [];
if (typeof VoicesManager.create === 'function') {
const manager = await VoicesManager.create();
voices = (await manager.getVoices?.()) || manager.voices || [];
} else {
const manager = new VoicesManager();
voices = (await manager.getVoices?.()) || manager.voices || [];
}
return voices
.map(normalizeVoice)
.filter((item) => !!item.shortName)
.sort((a, b) => {
const zhA = a.locale.startsWith('zh') ? 0 : 1;
const zhB = b.locale.startsWith('zh') ? 0 : 1;
return zhA - zhB || a.locale.localeCompare(b.locale) || a.shortName.localeCompare(b.shortName);
});
}
export async function synthesizeBookTts(input: {
sourceId: string;
bookId: string;
chapterHref: string;
text: string;
voice: string;
rate: string;
pitch: string;
volume: string;
}): Promise<TtsSynthesizeResult & { cacheKey: string; cacheHit: boolean }> {
const config = await getBookTtsConfig();
const normalizedText = input.text.trim();
if (!normalizedText) throw new Error('缺少朗读文本');
if (normalizedText.length > config.maxTextLengthPerRequest) {
throw new Error(`单次朗读文本过长,最多 ${config.maxTextLengthPerRequest} 个字符`);
}
const cacheKey = buildCacheKey({
...input,
text: normalizedText,
});
const mod = resolveEdgeTtsModule();
const EdgeTTS = mod.EdgeTTS || mod.default?.EdgeTTS;
if (!EdgeTTS) throw new Error('edge-tts-universal 未导出 EdgeTTS');
const tts = new EdgeTTS(
normalizedText,
input.voice || config.defaultVoice,
{
rate: input.rate || config.defaultRate,
pitch: input.pitch || config.defaultPitch,
volume: input.volume || config.defaultVolume,
}
);
const result = await tts.synthesize();
const payload: TtsSynthesizeResult = {
audioBuffer: await toBuffer(result?.audio),
mimeType: result?.mimeType || 'audio/mpeg',
boundaries: Array.isArray(result?.subtitle)
? result.subtitle.map(normalizeBoundary)
: Array.isArray(result?.subtitle?.words)
? result.subtitle?.words?.map(normalizeBoundary) || []
: Array.isArray(result?.subtitles)
? result.subtitles.map(normalizeBoundary)
: [],
};
return { ...payload, cacheKey, cacheHit: false };
}
+28
View File
@@ -128,6 +128,34 @@ export interface BookReadRecord {
saveTime: number;
}
export interface BookTtsVoice {
name: string;
shortName: string;
locale: string;
gender?: string;
displayName: string;
}
export interface BookTtsBoundary {
offset: number;
duration: number;
text: string;
}
export interface BookTtsProgress {
sourceId: string;
bookId: string;
chapterHref: string;
chapterTitle?: string;
chunkIndex: number;
charOffset: number;
voice: string;
rate: string;
pitch: string;
volume: string;
saveTime: number;
}
export interface BookReadManifest {
book: BookDetail;
format: 'epub' | 'pdf';
+12
View File
@@ -810,6 +810,18 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.MusicConfig.ProxyEnabled = true;
}
if (!adminConfig.OPDSConfig) {
adminConfig.OPDSConfig = {
Enabled: false,
Sources: [],
CacheTTL: 10 * 60 * 1000,
};
} else {
if (adminConfig.OPDSConfig.CacheTTL === undefined) {
adminConfig.OPDSConfig.CacheTTL = 10 * 60 * 1000;
}
}
return adminConfig;
}