电子书馆增加听书功能
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user