refactor(client): centralize client-side API calls and refine types
Abstracted direct fetch calls across components and contexts into new functions within `src/lib/client.ts`. This provides a consistent and centralized interface for interacting with backend APIs. - Introduced `src/lib/client.ts` to encapsulate API request logic. - Standardized audio buffer types (`TTSAudioBuffer`, `TTSAudioBytes`) in `src/types/tts.ts`. - Moved client-specific request types (`TTSRequestPayload`, `TTSRequestHeaders`, `TTSRetryOptions`) to `src/types/client.ts`. - Updated API routes and consumer components/contexts to leverage the new client library functions and type definitions. - Removed `src/utils/audio.ts` as its utility functions are now part of `src/lib/client.ts`.
This commit is contained in:
parent
7a29f73d07
commit
b576910523
20 changed files with 455 additions and 439 deletions
|
|
@ -4,12 +4,13 @@ import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises';
|
|||
import { existsSync, createReadStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
interface ConversionRequest {
|
||||
chapterTitle: string;
|
||||
buffer: number[];
|
||||
buffer: TTSAudioBytes;
|
||||
bookId?: string;
|
||||
format?: 'mp3' | 'm4b';
|
||||
format?: TTSAudiobookFormat;
|
||||
chapterIndex?: number;
|
||||
}
|
||||
|
||||
|
|
@ -206,9 +207,12 @@ export async function POST(request: NextRequest) {
|
|||
await unlink(inputPath).catch(console.error);
|
||||
|
||||
return NextResponse.json({
|
||||
index: chapterIndex,
|
||||
title: data.chapterTitle,
|
||||
duration,
|
||||
status: 'completed' as const,
|
||||
bookId,
|
||||
chapterIndex,
|
||||
duration
|
||||
format
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -229,7 +233,7 @@ export async function POST(request: NextRequest) {
|
|||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
||||
const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null;
|
||||
if (!bookId) {
|
||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
|
@ -405,4 +409,4 @@ export async function DELETE(request: NextRequest) {
|
|||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { readdir, readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -26,7 +27,7 @@ export async function GET(request: NextRequest) {
|
|||
duration?: number;
|
||||
status: 'completed' | 'error';
|
||||
bookId: string;
|
||||
format?: 'mp3' | 'm4b';
|
||||
format?: TTSAudiobookFormat;
|
||||
}> = [];
|
||||
|
||||
for (const metaFile of metaFiles) {
|
||||
|
|
@ -68,5 +69,3 @@ export async function GET(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
|||
import { isKokoroModel } from '@/utils/voice';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'crypto';
|
||||
import type { TTSRequestPayload, TTSError } from '@/types/tts';
|
||||
import type { TTSRequestPayload } from '@/types/client';
|
||||
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
|||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||
instructions?: string;
|
||||
};
|
||||
type AudioBufferValue = ArrayBuffer;
|
||||
type AudioBufferValue = TTSAudioBuffer;
|
||||
|
||||
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
||||
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
||||
|
|
@ -25,7 +26,7 @@ const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
|
|||
});
|
||||
|
||||
type InflightEntry = {
|
||||
promise: Promise<ArrayBuffer>;
|
||||
promise: Promise<TTSAudioBuffer>;
|
||||
controller: AbortController;
|
||||
consumers: number;
|
||||
};
|
||||
|
|
@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry(
|
|||
openai: OpenAI,
|
||||
createParams: ExtendedSpeechParams,
|
||||
signal: AbortSignal
|
||||
): Promise<ArrayBuffer> {
|
||||
): Promise<TTSAudioBuffer> {
|
||||
let attempt = 0;
|
||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||
|
|
@ -135,7 +136,7 @@ export async function POST(req: NextRequest) {
|
|||
voice: normalizedVoice,
|
||||
input: text,
|
||||
speed: speed,
|
||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
||||
response_format: format,
|
||||
};
|
||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
||||
|
|
@ -143,7 +144,7 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
// Compute cache key and check LRU before making provider call
|
||||
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
|
||||
const contentType = 'audio/mpeg';
|
||||
|
||||
// Preserve voice string as-is for cache key (no weight stripping)
|
||||
const voiceForKey = typeof createParams.voice === 'string'
|
||||
|
|
@ -245,7 +246,7 @@ export async function POST(req: NextRequest) {
|
|||
};
|
||||
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
let buffer: ArrayBuffer;
|
||||
let buffer: TTSAudioBuffer;
|
||||
try {
|
||||
buffer = await entry.promise;
|
||||
} finally {
|
||||
|
|
@ -280,4 +281,4 @@ export async function POST(req: NextRequest) {
|
|||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises';
|
|||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||
import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts';
|
||||
import { preprocessSentenceForAudio } from '@/lib/nlp';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
interface WhisperRequestBody {
|
||||
text: string;
|
||||
audio: number[]; // raw bytes from Uint8Array
|
||||
audio: TTSAudioBytes; // raw bytes from Uint8Array
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
|
|
@ -331,7 +331,7 @@ function mapWordsToSentenceOffsets(
|
|||
}
|
||||
|
||||
async function alignAudioWithText(
|
||||
audioBuffer: ArrayBuffer,
|
||||
audioBuffer: TTSAudioBuffer,
|
||||
text: string,
|
||||
cacheKey?: string,
|
||||
opts: WhisperAlignmentOptions = {}
|
||||
|
|
@ -448,4 +448,3 @@ export async function POST(req: NextRequest) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import TTSPlayer from '@/components/player/TTSPlayer';
|
|||
import { ZoomControl } from '@/components/ZoomControl';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import { DownloadIcon } from '@/components/icons/Icons';
|
||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
|
|
@ -86,8 +87,8 @@ export default function EPUBPage() {
|
|||
const handleGenerateAudiobook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal: AbortSignal,
|
||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||
format: 'mp3' | 'm4b'
|
||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||
format: TTSAudiobookFormat
|
||||
) => {
|
||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||
}, [createEPUBAudioBook, id]);
|
||||
|
|
@ -95,7 +96,7 @@ export default function EPUBPage() {
|
|||
const handleRegenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||
|
|
@ -190,4 +191,4 @@ export default function EPUBPage() {
|
|||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
|||
import { Header } from '@/components/Header';
|
||||
import { ZoomControl } from '@/components/ZoomControl';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
|
@ -80,8 +81,8 @@ export default function PDFViewerPage() {
|
|||
const handleGenerateAudiobook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal: AbortSignal,
|
||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||
format: 'mp3' | 'm4b'
|
||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||
format: TTSAudiobookFormat
|
||||
) => {
|
||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||
}, [createPDFAudioBook, id]);
|
||||
|
|
@ -89,7 +90,7 @@ export default function PDFViewerPage() {
|
|||
const handleRegenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
|
|||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||
import {
|
||||
getAudiobookStatus,
|
||||
deleteAudiobookChapter,
|
||||
deleteAudiobook,
|
||||
downloadAudiobookChapter,
|
||||
downloadAudiobook
|
||||
} from '@/lib/client';
|
||||
interface AudiobookExportModalProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
|
|
@ -19,12 +26,12 @@ interface AudiobookExportModalProps {
|
|||
onProgress: (progress: number) => void,
|
||||
signal: AbortSignal,
|
||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||
format: 'mp3' | 'm4b'
|
||||
format: TTSAudiobookFormat
|
||||
) => Promise<string>; // Returns bookId
|
||||
onRegenerateChapter?: (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
) => Promise<TTSAudiobookChapter>;
|
||||
}
|
||||
|
|
@ -46,7 +53,7 @@ export function AudiobookExportModal({
|
|||
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
||||
const [currentChapter, setCurrentChapter] = useState<string>('');
|
||||
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
||||
const [format, setFormat] = useState<TTSAudiobookFormat>('m4b');
|
||||
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
||||
|
|
@ -61,26 +68,23 @@ export function AudiobookExportModal({
|
|||
setIsLoadingExisting(true);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/audiobook/status?bookId=${documentId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.exists && data.chapters.length > 0) {
|
||||
setChapters(data.chapters);
|
||||
setBookId(data.bookId);
|
||||
// Set format from existing chapters - this ensures the format matches what was actually generated
|
||||
if (data.chapters[0]?.format) {
|
||||
const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b';
|
||||
setFormat(detectedFormat);
|
||||
}
|
||||
// If we have a complete audiobook, we're done
|
||||
if (data.hasComplete) {
|
||||
setProgress(100);
|
||||
}
|
||||
} else {
|
||||
// If nothing exists, clear chapters/bookId to reflect current state
|
||||
setChapters([]);
|
||||
setBookId(null);
|
||||
const data = await getAudiobookStatus(documentId);
|
||||
if (data.exists && data.chapters.length > 0) {
|
||||
setChapters(data.chapters);
|
||||
setBookId(data.bookId);
|
||||
// Set format from existing chapters - this ensures the format matches what was actually generated
|
||||
if (data.chapters[0]?.format) {
|
||||
const detectedFormat = data.chapters[0].format as TTSAudiobookFormat;
|
||||
setFormat(detectedFormat);
|
||||
}
|
||||
// If we have a complete audiobook, we're done
|
||||
if (data.hasComplete) {
|
||||
setProgress(100);
|
||||
}
|
||||
} else {
|
||||
// If nothing exists, clear chapters/bookId to reflect current state
|
||||
setChapters([]);
|
||||
setBookId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching existing chapters:', error);
|
||||
|
|
@ -241,12 +245,7 @@ export function AudiobookExportModal({
|
|||
const performDeleteChapter = useCallback(async () => {
|
||||
if (!bookId || !pendingDeleteChapter) return;
|
||||
try {
|
||||
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
await deleteAudiobookChapter(bookId, pendingDeleteChapter.index);
|
||||
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
||||
await fetchExistingChapters(true);
|
||||
} catch (error) {
|
||||
|
|
@ -261,10 +260,7 @@ export function AudiobookExportModal({
|
|||
const targetBookId = bookId || documentId;
|
||||
if (!targetBookId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/audiobook?bookId=${targetBookId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Reset failed');
|
||||
}
|
||||
await deleteAudiobook(targetBookId);
|
||||
setChapters([]);
|
||||
setBookId(null);
|
||||
setProgress(0);
|
||||
|
|
@ -281,10 +277,7 @@ export function AudiobookExportModal({
|
|||
if (!chapter.bookId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/audiobook/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
|
@ -306,8 +299,7 @@ export function AudiobookExportModal({
|
|||
|
||||
setIsCombining(true);
|
||||
try {
|
||||
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const response = await downloadAudiobook(bookId, format);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
|||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
|
|
@ -80,8 +81,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
const handleGenerateAudiobook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal: AbortSignal,
|
||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||
format: 'mp3' | 'm4b'
|
||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||
format: TTSAudiobookFormat
|
||||
) => {
|
||||
if (epub) {
|
||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||
|
|
@ -93,7 +94,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
const handleRegenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
if (epub) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
|
|||
import { useDropzone } from 'react-dropzone';
|
||||
import { UploadIcon } from '@/components/icons/Icons';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import { convertDocxToPdf as convertDocxToPdfClient } from '@/lib/client';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
|
|
@ -23,19 +24,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const convertDocxToPdf = async (file: File): Promise<File> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/documents/docx-to-pdf', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to convert DOCX to PDF');
|
||||
}
|
||||
|
||||
const pdfBlob = await response.blob();
|
||||
const pdfBlob = await convertDocxToPdfClient(file);
|
||||
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|||
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||
import { THEMES } from '@/contexts/ThemeContext';
|
||||
import { deleteServerDocuments } from '@/lib/client';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
|
|
@ -222,12 +223,7 @@ export function SettingsModal() {
|
|||
|
||||
const handleClearServer = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/documents', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete server documents');
|
||||
}
|
||||
await deleteServerDocuments();
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,18 @@ import { useTTS } from '@/contexts/TTSContext';
|
|||
import { createRangeCfi } from '@/lib/epub';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { withRetry } from '@/utils/audio';
|
||||
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRequestPayload,
|
||||
TTSRetryOptions,
|
||||
TTSSentenceAlignment
|
||||
} from '@/types/tts';
|
||||
} from '@/types/client';
|
||||
|
||||
interface EPUBContextType {
|
||||
currDocData: ArrayBuffer | undefined;
|
||||
|
|
@ -39,8 +43,8 @@ interface EPUBContextType {
|
|||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
clearCurrDoc: () => void;
|
||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
|
||||
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||
bookRef: RefObject<Book | null>;
|
||||
renditionRef: RefObject<Rendition | undefined>;
|
||||
tocRef: RefObject<NavItem[]>;
|
||||
|
|
@ -319,9 +323,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const createFullAudioBook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||
providedBookId?: string,
|
||||
format: 'mp3' | 'm4b' = 'mp3'
|
||||
format: TTSAudiobookFormat = 'mp3'
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
|
|
@ -339,18 +343,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingResponse = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
||||
if (existingResponse.ok) {
|
||||
const existingData = await existingResponse.json();
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
// Log smallest missing index for visibility
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
// Log smallest missing index for visibility
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
|
|
@ -431,22 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const ttsResponse = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const buffer = await ttsResponse.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -469,45 +455,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const convertResponse = await fetch(`/api/audiobook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex: i
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
if (!convertResponse.ok) {
|
||||
throw new Error('Failed to convert audio chapter');
|
||||
}
|
||||
|
||||
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex: i
|
||||
}, signal);
|
||||
|
||||
if (!bookId) {
|
||||
bookId = returnedBookId;
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: chapterIndex,
|
||||
title: chapterTitle,
|
||||
duration,
|
||||
status: 'completed',
|
||||
bookId,
|
||||
format
|
||||
});
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += trimmedText.length;
|
||||
|
|
@ -553,9 +515,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const regenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (chapterIndex >= sections.length) {
|
||||
|
|
@ -620,22 +582,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const ttsResponse = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const buffer = await ttsResponse.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -645,39 +592,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const convertResponse = await fetch('/api/audiobook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
if (!convertResponse.ok) {
|
||||
throw new Error('Failed to convert audio chapter');
|
||||
}
|
||||
|
||||
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||
|
||||
return {
|
||||
index: returnedIndex,
|
||||
title: chapterTitle,
|
||||
duration,
|
||||
status: 'completed',
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format
|
||||
};
|
||||
format,
|
||||
chapterIndex
|
||||
}, signal);
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { getPdfDocument } from '@/lib/dexie';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { processTextToSentences } from '@/lib/nlp';
|
||||
import { withRetry } from '@/utils/audio';
|
||||
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
|
|
@ -40,12 +40,17 @@ import {
|
|||
highlightWordIndex,
|
||||
} from '@/lib/pdf';
|
||||
|
||||
import type {
|
||||
TTSSentenceAlignment,
|
||||
TTSAudioBuffer,
|
||||
TTSAudiobookFormat,
|
||||
TTSAudiobookChapter,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestHeaders,
|
||||
TTSRequestPayload,
|
||||
TTSRetryOptions,
|
||||
TTSSentenceAlignment
|
||||
} from '@/types/tts';
|
||||
} from '@/types/client';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties in the PDF context
|
||||
|
|
@ -72,8 +77,8 @@ interface PDFContextType {
|
|||
sentence: string | null | undefined,
|
||||
containerRef: RefObject<HTMLDivElement>
|
||||
) => void;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
|
||||
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||
isAudioCombining: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -257,9 +262,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const createFullAudioBook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||
providedBookId?: string,
|
||||
format: 'mp3' | 'm4b' = 'mp3'
|
||||
format: TTSAudiobookFormat = 'mp3'
|
||||
): Promise<string> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
|
|
@ -299,17 +304,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const existingIndices = new Set<number>();
|
||||
if (bookId) {
|
||||
try {
|
||||
const existingResponse = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
||||
if (existingResponse.ok) {
|
||||
const existingData = await existingResponse.json();
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||
const existingData = await getAudiobookStatus(bookId);
|
||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||
for (const ch of existingData.chapters) {
|
||||
existingIndices.add(ch.index);
|
||||
}
|
||||
let nextMissing = 0;
|
||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking existing chapters:', error);
|
||||
|
|
@ -367,22 +369,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const ttsResponse = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const buffer = await ttsResponse.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -399,45 +386,21 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const convertResponse = await fetch(`/api/audiobook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex: i
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
if (!convertResponse.ok) {
|
||||
throw new Error('Failed to convert audio chapter');
|
||||
}
|
||||
|
||||
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex: i
|
||||
}, signal);
|
||||
|
||||
if (!bookId) {
|
||||
bookId = returnedBookId;
|
||||
bookId = chapter.bookId!;
|
||||
}
|
||||
|
||||
// Notify about completed chapter
|
||||
if (onChapterComplete) {
|
||||
onChapterComplete({
|
||||
index: chapterIndex,
|
||||
title: chapterTitle,
|
||||
duration,
|
||||
status: 'completed',
|
||||
bookId,
|
||||
format
|
||||
});
|
||||
onChapterComplete(chapter);
|
||||
}
|
||||
|
||||
processedLength += text.length;
|
||||
|
|
@ -483,9 +446,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const regenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
bookId: string,
|
||||
format: 'mp3' | 'm4b',
|
||||
format: TTSAudiobookFormat,
|
||||
signal: AbortSignal
|
||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
|
|
@ -556,28 +519,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
backoffFactor: 2
|
||||
};
|
||||
|
||||
const audioBuffer = await withRetry(
|
||||
const audioBuffer: TTSAudioBuffer = await withRetry(
|
||||
async () => {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const ttsResponse = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const buffer = await ttsResponse.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
return await generateTTS(reqBody, reqHeaders, signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
@ -587,39 +535,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
// Send to server for conversion and storage
|
||||
const convertResponse = await fetch('/api/audiobook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format,
|
||||
chapterIndex
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
if (!convertResponse.ok) {
|
||||
throw new Error('Failed to convert audio chapter');
|
||||
}
|
||||
|
||||
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||
|
||||
return {
|
||||
index: returnedIndex,
|
||||
title: chapterTitle,
|
||||
duration,
|
||||
status: 'completed',
|
||||
const chapter = await createAudiobookChapter({
|
||||
chapterTitle,
|
||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||
bookId,
|
||||
format
|
||||
};
|
||||
format,
|
||||
chapterIndex
|
||||
}, signal);
|
||||
|
||||
return chapter;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
|||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||
import { withRetry } from '@/utils/audio';
|
||||
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
||||
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||
import { isKokoroModel } from '@/utils/voice';
|
||||
import type {
|
||||
|
|
@ -44,11 +44,14 @@ import type {
|
|||
TTSSmartMergeResult,
|
||||
TTSPageTurnEstimate,
|
||||
TTSPlaybackState,
|
||||
TTSSentenceAlignment,
|
||||
TTSAudioBuffer,
|
||||
} from '@/types/tts';
|
||||
import type {
|
||||
TTSRequestPayload,
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
TTSSentenceAlignment,
|
||||
} from '@/types/tts';
|
||||
} from '@/types/client';
|
||||
|
||||
// Media globals
|
||||
declare global {
|
||||
|
|
@ -731,16 +734,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
* Generates and plays audio for the current sentence
|
||||
*
|
||||
* @param {string} sentence - The sentence to generate audio for
|
||||
* @returns {Promise<ArrayBuffer | undefined>} The generated audio buffer
|
||||
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
|
||||
*/
|
||||
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
|
||||
const getAudio = useCallback(async (sentence: string): Promise<TTSAudioBuffer | undefined> => {
|
||||
const alignmentEnabledForCurrentDoc =
|
||||
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
|
||||
// Helper to ensure we have an alignment for a given
|
||||
// sentence/audio pair, even when the audio itself is
|
||||
// served from the local cache.
|
||||
const ensureAlignment = (arrayBuffer: ArrayBuffer) => {
|
||||
const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => {
|
||||
if (!alignmentEnabledForCurrentDoc) return;
|
||||
if (sentenceAlignmentCacheRef.current.has(sentence)) return;
|
||||
|
||||
|
|
@ -751,16 +754,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
audio: audioBytes,
|
||||
};
|
||||
|
||||
void fetch('/api/whisper', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(alignmentBody),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json().catch(() => null);
|
||||
void alignAudio(alignmentBody)
|
||||
.then(async (data) => {
|
||||
if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -825,19 +820,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
const arrayBuffer = await withRetry(
|
||||
async () => {
|
||||
|
||||
const response = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate audio');
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
return await generateTTS(reqBody, reqHeaders, controller.signal);
|
||||
},
|
||||
retryOptions
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useRef } from 'react';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import type { TTSAudioBuffer } from '@/types/tts';
|
||||
|
||||
/**
|
||||
* Custom hook for managing audio cache using LRU strategy
|
||||
|
|
@ -9,11 +10,11 @@ import { LRUCache } from 'lru-cache';
|
|||
* @returns Object containing cache methods
|
||||
*/
|
||||
export function useAudioCache(maxSize = 50) {
|
||||
const cacheRef = useRef(new LRUCache<string, ArrayBuffer>({ max: maxSize }));
|
||||
const cacheRef = useRef(new LRUCache<string, TTSAudioBuffer>({ max: maxSize }));
|
||||
|
||||
return {
|
||||
get: (key: string) => cacheRef.current.get(key),
|
||||
set: (key: string, value: ArrayBuffer) => cacheRef.current.set(key, value),
|
||||
set: (key: string, value: TTSAudioBuffer) => cacheRef.current.set(key, value),
|
||||
delete: (key: string) => cacheRef.current.delete(key),
|
||||
has: (key: string) => cacheRef.current.has(key),
|
||||
clear: () => cacheRef.current.clear(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getVoices } from '@/lib/client';
|
||||
|
||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||
|
||||
|
|
@ -23,18 +24,14 @@ export function useVoiceManagement(
|
|||
const fetchVoices = useCallback(async () => {
|
||||
try {
|
||||
console.log('Fetching voices...');
|
||||
const response = await fetch('/api/tts/voices', {
|
||||
headers: {
|
||||
'x-openai-key': apiKey || '',
|
||||
'x-openai-base-url': baseUrl || '',
|
||||
'x-tts-provider': ttsProvider || 'openai',
|
||||
'x-tts-model': ttsModel || 'tts-1',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
const data = await getVoices({
|
||||
'x-openai-key': apiKey || '',
|
||||
'x-openai-base-url': baseUrl || '',
|
||||
'x-tts-provider': ttsProvider || 'openai',
|
||||
'x-tts-model': ttsModel || 'tts-1',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch voices');
|
||||
const data = await response.json();
|
||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
||||
} catch (error) {
|
||||
console.error('Error fetching voices:', error);
|
||||
|
|
|
|||
205
src/lib/client.ts
Normal file
205
src/lib/client.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import type {
|
||||
TTSRequestPayload,
|
||||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
AudiobookStatusResponse,
|
||||
CreateChapterPayload,
|
||||
VoicesResponse,
|
||||
AlignmentPayload,
|
||||
AlignmentResponse
|
||||
} from '@/types/client';
|
||||
import type { TTSAudiobookChapter, TTSAudioBuffer } from '@/types/tts';
|
||||
|
||||
/**
|
||||
* Executes a function with exponential backoff retry logic
|
||||
* @param operation Function to retry
|
||||
* @param options Retry configuration options
|
||||
* @returns Promise resolving to the operation result
|
||||
*/
|
||||
export const withRetry = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
options: TTSRetryOptions = {}
|
||||
): Promise<T> => {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 10000,
|
||||
backoffFactor = 2
|
||||
} = options;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Do not retry on explicit cancellation/abort errors - surface them
|
||||
// immediately so callers can stop work quickly when the user cancels.
|
||||
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt === maxRetries - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
initialDelay * Math.pow(backoffFactor, attempt),
|
||||
maxDelay
|
||||
);
|
||||
|
||||
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Operation failed after retries');
|
||||
};
|
||||
|
||||
// --- Documents API ---
|
||||
|
||||
export const convertDocxToPdf = async (file: File): Promise<Blob> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/documents/docx-to-pdf', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to convert DOCX to PDF');
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
};
|
||||
|
||||
export const deleteServerDocuments = async (): Promise<void> => {
|
||||
const response = await fetch('/api/documents', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete server documents');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Audiobook API ---
|
||||
|
||||
|
||||
|
||||
export const getAudiobookStatus = async (bookId: string): Promise<AudiobookStatusResponse> => {
|
||||
const response = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch audiobook status');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const createAudiobookChapter = async (
|
||||
payload: CreateChapterPayload,
|
||||
signal?: AbortSignal
|
||||
): Promise<TTSAudiobookChapter> => {
|
||||
const response = await fetch(`/api/audiobook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
});
|
||||
|
||||
if (response.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to convert audio chapter');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteAudiobook = async (bookId: string): Promise<void> => {
|
||||
const response = await fetch(`/api/audiobook?bookId=${bookId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Reset failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadAudiobook = async (bookId: string, format: string): Promise<Response> => {
|
||||
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
return response;
|
||||
};
|
||||
|
||||
export const deleteAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<void> => {
|
||||
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<Blob> => {
|
||||
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
return await response.blob();
|
||||
};
|
||||
|
||||
// --- TTS API ---
|
||||
|
||||
|
||||
|
||||
export const getVoices = async (headers: HeadersInit): Promise<VoicesResponse> => {
|
||||
const response = await fetch('/api/tts/voices', {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch voices');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const generateTTS = async (
|
||||
payload: TTSRequestPayload,
|
||||
headers: TTSRequestHeaders,
|
||||
signal?: AbortSignal
|
||||
): Promise<TTSAudioBuffer> => {
|
||||
const response = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: headers as HeadersInit,
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`TTS processing failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
// --- Whisper API ---
|
||||
|
||||
|
||||
|
||||
export const alignAudio = async (payload: AlignmentPayload): Promise<AlignmentResponse | null> => {
|
||||
const response = await fetch('/api/whisper', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
};
|
||||
|
|
@ -2,7 +2,6 @@ import { pdfjs } from 'react-pdf';
|
|||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
||||
import "core-js/proposals/promise-with-resolvers";
|
||||
import { processTextToSentences } from '@/lib/nlp';
|
||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||
import { CmpStr } from 'cmpstr';
|
||||
|
||||
|
|
|
|||
67
src/types/client.ts
Normal file
67
src/types/client.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type {
|
||||
TTSAudiobookChapter,
|
||||
TTSSentenceAlignment,
|
||||
TTSAudioBytes,
|
||||
TTSAudiobookFormat,
|
||||
} from '@/types/tts';
|
||||
|
||||
// --- TTS Client Request Types ---
|
||||
|
||||
// Supported output formats for the TTS endpoint
|
||||
export type TTSRequestFormat = 'mp3';
|
||||
|
||||
// JSON payload accepted by the /api/tts endpoint
|
||||
export interface TTSRequestPayload {
|
||||
text: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
model?: string | null;
|
||||
format?: TTSRequestFormat;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
// Headers used when calling the /api/tts endpoint from the client
|
||||
export type TTSRequestHeaders = Record<string, string>;
|
||||
|
||||
// Options for retrying TTS requests on failure in withRetry
|
||||
export interface TTSRetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
// --- Audiobook API Types ---
|
||||
|
||||
export interface AudiobookStatusResponse {
|
||||
exists: boolean;
|
||||
chapters: TTSAudiobookChapter[];
|
||||
bookId: string | null;
|
||||
hasComplete: boolean;
|
||||
}
|
||||
|
||||
export interface CreateChapterPayload {
|
||||
chapterTitle: string;
|
||||
buffer: TTSAudioBytes; // Array.from(new Uint8Array(audioBuffer))
|
||||
bookId: string;
|
||||
format: TTSAudiobookFormat;
|
||||
chapterIndex: number;
|
||||
}
|
||||
|
||||
|
||||
// --- TTS Voices API Types ---
|
||||
|
||||
export interface VoicesResponse {
|
||||
voices: string[];
|
||||
}
|
||||
|
||||
// --- Whisper API Types ---
|
||||
|
||||
export interface AlignmentPayload {
|
||||
text: string;
|
||||
audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer))
|
||||
}
|
||||
|
||||
export interface AlignmentResponse {
|
||||
alignments: TTSSentenceAlignment[];
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
export type TTSLocation = string | number;
|
||||
|
||||
// Core audio representations used across TTS and audiobook flows
|
||||
export type TTSAudioBuffer = ArrayBuffer;
|
||||
export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer)))
|
||||
|
||||
// Standardized error codes for the TTS API
|
||||
export type TTSErrorCode =
|
||||
| 'MISSING_PARAMETERS'
|
||||
|
|
@ -15,22 +19,6 @@ export interface TTSError {
|
|||
details?: unknown;
|
||||
}
|
||||
|
||||
// Supported output formats for the TTS endpoint
|
||||
export type TTSRequestFormat = 'mp3' | 'aac';
|
||||
|
||||
// JSON payload accepted by the /api/tts endpoint
|
||||
export interface TTSRequestPayload {
|
||||
text: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
model?: string | null;
|
||||
format?: TTSRequestFormat;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
// Headers used when calling the /api/tts endpoint from the client
|
||||
export type TTSRequestHeaders = Record<string, string>;
|
||||
|
||||
// Core playback state exposed by the TTS context
|
||||
export interface TTSPlaybackState {
|
||||
isPlaying: boolean;
|
||||
|
|
@ -42,14 +30,6 @@ export interface TTSPlaybackState {
|
|||
currDocPages?: number;
|
||||
}
|
||||
|
||||
// Options for retrying TTS requests on failure in withRetry
|
||||
export interface TTSRetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
// Result of merging a continuation slice into the current text
|
||||
export interface TTSSmartMergeResult {
|
||||
text: string;
|
||||
|
|
@ -79,6 +59,9 @@ export interface TTSSentenceAlignment {
|
|||
words: TTSSentenceWord[];
|
||||
}
|
||||
|
||||
// Supported output formats for generated audiobooks
|
||||
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
||||
|
||||
// Metadata for an audiobook chapter
|
||||
export interface TTSAudiobookChapter {
|
||||
index: number;
|
||||
|
|
@ -86,5 +69,5 @@ export interface TTSAudiobookChapter {
|
|||
duration?: number;
|
||||
status: 'pending' | 'generating' | 'completed' | 'error';
|
||||
bookId?: string;
|
||||
format?: 'mp3' | 'm4b';
|
||||
format?: TTSAudiobookFormat;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import type { TTSRetryOptions } from '@/types/tts';
|
||||
|
||||
/**
|
||||
* Executes a function with exponential backoff retry logic
|
||||
* @param operation Function to retry
|
||||
* @param options Retry configuration options
|
||||
* @returns Promise resolving to the operation result
|
||||
*/
|
||||
export const withRetry = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
options: TTSRetryOptions = {}
|
||||
): Promise<T> => {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 10000,
|
||||
backoffFactor = 2
|
||||
} = options;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Do not retry on explicit cancellation/abort errors - surface them
|
||||
// immediately so callers can stop work quickly when the user cancels.
|
||||
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt === maxRetries - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
initialDelay * Math.pow(backoffFactor, attempt),
|
||||
maxDelay
|
||||
);
|
||||
|
||||
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Operation failed after retries');
|
||||
};
|
||||
Loading…
Reference in a new issue