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:
Richard Roberson 2025-11-21 23:33:41 -07:00
parent 7a29f73d07
commit b576910523
20 changed files with 455 additions and 439 deletions

View file

@ -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 }
);
}
}
}

View file

@ -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) {
);
}
}

View file

@ -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 }
);
}
}
}

View file

@ -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) {
);
}
}

View file

@ -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} />
</>
);
}
}

View file

@ -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);

View file

@ -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');

View file

@ -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) {

View file

@ -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',
});

View file

@ -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);
}

View file

@ -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'))) {

View file

@ -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'))) {

View file

@ -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
);

View file

@ -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(),

View file

@ -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
View 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();
};

View file

@ -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
View 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[];
}

View file

@ -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;
}

View file

@ -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');
};