diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index b37dfae..17bf12a 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -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 } ); } -} \ No newline at end of file +} diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index d682a77..44e8fd6 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -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) { ); } } - - diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index a075d41..ff3680b 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -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 & { 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({ }); type InflightEntry = { - promise: Promise; + promise: Promise; controller: AbortController; consumers: number; }; @@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry( openai: OpenAI, createParams: ExtendedSpeechParams, signal: AbortSignal -): Promise { +): Promise { 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 } ); } -} \ No newline at end of file +} diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index 146e452..a0d0da7 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -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) { ); } } - diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 96f8b37..325b96c 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -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() { ); -} \ No newline at end of file +} diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 7cd6957..276dc52 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -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); diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index c9a022c..d9712bd 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -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; // Returns bookId onRegenerateChapter?: ( chapterIndex: number, bookId: string, - format: 'mp3' | 'm4b', + format: TTSAudiobookFormat, signal: AbortSignal ) => Promise; } @@ -46,7 +53,7 @@ export function AudiobookExportModal({ const [isLoadingExisting, setIsLoadingExisting] = useState(false); const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); const [currentChapter, setCurrentChapter] = useState(''); - const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b'); + const [format, setFormat] = useState('m4b'); const [regeneratingChapter, setRegeneratingChapter] = useState(null); const abortControllerRef = useRef(null); const [pendingDeleteChapter, setPendingDeleteChapter] = useState(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'); diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 76a3e7f..84abae2 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -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) { diff --git a/src/components/DocumentUploader.tsx b/src/components/DocumentUploader.tsx index 0f4d58a..cd35696 100644 --- a/src/components/DocumentUploader.tsx +++ b/src/components/DocumentUploader.tsx @@ -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(null); const convertDocxToPdf = async (file: File): Promise => { - 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', }); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index db2e421..22a0c05 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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); } diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index b555ff5..365295f 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -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; clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; - 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; - 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; + regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise; bookRef: RefObject; renditionRef: RefObject; tocRef: RefObject; @@ -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 => { try { const sections = await extractBookText(); @@ -339,18 +343,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const existingIndices = new Set(); 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 => { 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'))) { diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index fb03eb8..72a2340 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -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 ) => 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; - 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; + regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise; 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 => { try { if (!pdfDocument) { @@ -299,17 +304,14 @@ export function PDFProvider({ children }: { children: ReactNode }) { const existingIndices = new Set(); 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 => { 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'))) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 4124f13..afaa129 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -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} The generated audio buffer + * @returns {Promise} The generated audio buffer */ - const getAudio = useCallback(async (sentence: string): Promise => { + const getAudio = useCallback(async (sentence: string): Promise => { 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 ); diff --git a/src/hooks/audio/useAudioCache.ts b/src/hooks/audio/useAudioCache.ts index 096176a..f71d0fa 100644 --- a/src/hooks/audio/useAudioCache.ts +++ b/src/hooks/audio/useAudioCache.ts @@ -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({ max: maxSize })); + const cacheRef = useRef(new LRUCache({ 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(), diff --git a/src/hooks/audio/useVoiceManagement.ts b/src/hooks/audio/useVoiceManagement.ts index 6e5ed0b..67f0d6f 100644 --- a/src/hooks/audio/useVoiceManagement.ts +++ b/src/hooks/audio/useVoiceManagement.ts @@ -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); diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..f6ca16d --- /dev/null +++ b/src/lib/client.ts @@ -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 ( + operation: () => Promise, + options: TTSRetryOptions = {} +): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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(); +}; diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index a8e938b..fc4c120 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -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'; diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..113b6ad --- /dev/null +++ b/src/types/client.ts @@ -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; + +// 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[]; +} diff --git a/src/types/tts.ts b/src/types/tts.ts index a2744b2..5a944a4 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -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; - // 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; } diff --git a/src/utils/audio.ts b/src/utils/audio.ts deleted file mode 100644 index db46302..0000000 --- a/src/utils/audio.ts +++ /dev/null @@ -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 ( - operation: () => Promise, - options: TTSRetryOptions = {} -): Promise => { - 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'); -}; \ No newline at end of file