diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 2e0dade..a075d41 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -4,6 +4,7 @@ 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'; export const runtime = 'nodejs'; @@ -102,14 +103,20 @@ export async function POST(req: NextRequest) { const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const provider = req.headers.get('x-tts-provider') || 'openai'; - const { text, voice, speed, format, model: req_model, instructions } = await req.json(); + const body = (await req.json()) as TTSRequestPayload; + const { text, voice, speed, format, model: req_model, instructions } = body; console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); if (!text || !voice || !speed) { - return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); + const errorBody: TTSError = { + code: 'MISSING_PARAMETERS', + message: 'Missing required parameters', + }; + return NextResponse.json(errorBody, { status: 400 }); } - // Use default Kokoro model for Deepinfra if none specified - const model = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; + // Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default + const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; + const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model']; // Initialize OpenAI client with abort signal (OpenAI/deepinfra) const openai = new OpenAI({ @@ -118,7 +125,7 @@ export async function POST(req: NextRequest) { }); const normalizedVoice = ( - !isKokoroModel(model) && voice.includes('+') + !isKokoroModel(model as string) && voice.includes('+') ? (voice.split('+')[0].trim()) : voice ) as SpeechCreateParams['voice']; @@ -131,7 +138,7 @@ export async function POST(req: NextRequest) { response_format: format === 'aac' ? 'aac' : 'mp3', }; // Only add instructions if model is gpt-4o-mini-tts and instructions are provided - if (model === 'gpt-4o-mini-tts' && instructions) { + if ((model as string) === 'gpt-4o-mini-tts' && instructions) { createParams.instructions = instructions; } @@ -263,8 +270,13 @@ export async function POST(req: NextRequest) { } console.warn('Error generating TTS:', error); + const errorBody: TTSError = { + code: 'TTS_GENERATION_FAILED', + message: 'Failed to generate audio', + details: process.env.NODE_ENV !== 'production' ? String(error) : undefined, + }; return NextResponse.json( - { error: 'Failed to generate audio' }, + errorBody, { status: 500 } ); } diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index a2b6e3e..769460f 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -388,7 +388,6 @@ export function AudiobookExportModal({ progress={progress} estimatedTimeRemaining={estimatedTimeRemaining || undefined} onCancel={handleCancel} - isProcessing={isCombining} cancelText="Cancel" operationType="audiobook" onClick={() => setIsOpen(true)} @@ -531,7 +530,6 @@ export function AudiobookExportModal({ progress={progress} estimatedTimeRemaining={estimatedTimeRemaining || undefined} onCancel={handleCancel} - isProcessing={isCombining} operationType="audiobook" currentChapter={currentChapter} completedChapters={chapters.filter(c => c.status === 'completed').length} diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index c95a6de..f3fb414 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -2,6 +2,7 @@ import { RefObject, useCallback, useState, useEffect, useRef } from 'react'; import { Document, Page } from 'react-pdf'; +import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; @@ -14,9 +15,9 @@ interface PDFViewerProps { zoomLevel: number; } -interface OnItemClickArgs { +interface PDFOnLinkClickArgs { pageNumber?: number; - dest?: unknown; + dest?: Dest; } export function PDFViewer({ zoomLevel }: PDFViewerProps) { @@ -126,12 +127,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); }} - onItemClick={(args: OnItemClickArgs) => { + onItemClick={(args: PDFOnLinkClickArgs) => { if (args?.pageNumber) { skipToLocation(args.pageNumber, true); } else if (args?.dest) { - const destArray = Array.isArray(args.dest) ? args.dest : []; - const pageNum = typeof destArray[0] === 'number' ? destArray[0] + 1 : undefined; + const destArray = args.dest as Array || []; + const pageNum = destArray[0] + 1 || null; if (pageNum) { skipToLocation(pageNum, true); } diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index 4468540..4f330d2 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -1,10 +1,7 @@ -import { LoadingSpinner } from './Spinner'; - interface ProgressCardProps { progress: number; estimatedTimeRemaining?: string; onCancel: (e?: React.MouseEvent) => void; - isProcessing?: boolean; operationType?: 'sync' | 'load' | 'audiobook'; cancelText?: string; currentChapter?: string; @@ -16,7 +13,6 @@ export function ProgressCard({ progress, estimatedTimeRemaining, onCancel, - isProcessing = false, operationType, cancelText = 'Cancel', currentChapter, @@ -58,11 +54,6 @@ export function ProgressCard({ className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" onClick={(e) => onCancel(e)} > - {isProcessing && ( - - - - )} {cancelText} diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index ab1ae7d..7f37269 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -7,7 +7,6 @@ interface ProgressPopupProps { progress: number; estimatedTimeRemaining?: string; onCancel: () => void; - isProcessing: boolean; statusMessage?: string; operationType?: 'sync' | 'load' | 'audiobook'; cancelText?: string; @@ -22,7 +21,6 @@ export function ProgressPopup({ progress, estimatedTimeRemaining, onCancel, - isProcessing, statusMessage, operationType, cancelText = 'Cancel', @@ -56,7 +54,6 @@ export function ProgressPopup({ e?.stopPropagation(); onCancel(); }} - isProcessing={isProcessing} operationType={operationType} cancelText={cancelText} currentChapter={currentChapter} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 42e5c4a..5c6762a 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,7 +22,7 @@ import { import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/utils/dexie'; +import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -699,7 +699,6 @@ export function SettingsModal() { setOperationType('sync'); setAbortController(null); }} - isProcessing={isSyncing || isLoading} statusMessage={statusMessage} operationType={operationType} cancelText="Cancel" diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 0878620..2bb8d2b 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -5,7 +5,7 @@ import { useDocuments } from '@/contexts/DocumentContext'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents'; -import { getDocumentListState, saveDocumentListState } from '@/utils/dexie'; +import { getDocumentListState, saveDocumentListState } from '@/lib/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { DocumentListItem } from '@/components/doclist/DocumentListItem'; import { DocumentFolder } from '@/components/doclist/DocumentFolder'; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index ac5be7d..f119d33 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -2,12 +2,11 @@ import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, initDB, updateAppConfig } from '@/utils/dexie'; -import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig'; -export type { ViewType } from '@/types/appConfig'; +import { db, initDB, updateAppConfig } from '@/lib/dexie'; +import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; +export type { ViewType } from '@/types/config'; /** Configuration values for the application */ -type ConfigValues = Omit; /** Interface defining the configuration context shape and functionality */ interface ConfigContextType { @@ -29,7 +28,7 @@ interface ConfigContextType { ttsInstructions: string; savedVoices: SavedVoices; updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise; - updateConfigKey: (key: K, value: ConfigValues[K]) => Promise; + updateConfigKey: (key: K, value: AppConfigValues[K]) => Promise; isLoading: boolean; isDBReady: boolean; pdfHighlightEnabled: boolean; @@ -76,7 +75,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { null, ); - const config: ConfigValues | null = useMemo(() => { + const config: AppConfigValues | null = useMemo(() => { if (!appConfig) return null; const { id, ...rest } = appConfig; void id; @@ -131,9 +130,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { /** * Updates a single configuration value by key * @param {K} key - The configuration key to update - * @param {ConfigValues[K]} value - The new value for the configuration + * @param {AppConfigValues[K]} value - The new value for the configuration */ - const updateConfigKey = async (key: K, value: ConfigValues[K]) => { + const updateConfigKey = async (key: K, value: AppConfigValues[K]) => { try { setIsLoading(true); @@ -153,7 +152,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const voiceKey = getVoiceKey(newProvider, newModel); const restoredVoice = savedVoices[voiceKey] || ''; await updateAppConfig({ - [key]: value as ConfigValues[keyof ConfigValues], + [key]: value as AppConfigValues[keyof AppConfigValues], voice: restoredVoice, } as Partial); } @@ -165,7 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { } else { await updateAppConfig({ - [key]: value as ConfigValues[keyof ConfigValues], + [key]: value as AppConfigValues[keyof AppConfigValues], } as Partial); } } catch (error) { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 25d849f..84ddbe3 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -10,15 +10,18 @@ import { useRef, RefObject } from 'react'; -import { getEpubDocument, setLastDocumentLocation } from '@/utils/dexie'; -import { useTTS } from '@/contexts/TTSContext'; -import { Book, Rendition } from 'epubjs'; -import { createRangeCfi } from '@/utils/epub'; + import type { NavItem } from 'epubjs'; -import { SpineItem } from 'epubjs/types/section'; +import type { SpineItem } from 'epubjs/types/section'; +import type { Book, Rendition } from 'epubjs'; + +import { getEpubDocument, setLastDocumentLocation } from '@/lib/dexie'; +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 type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts'; interface EPUBContextType { currDocData: ArrayBuffer | undefined; @@ -371,6 +374,29 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } try { + const reqHeaders: TTSRequestHeaders = { + 'Content-Type': 'application/json', + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + 'x-tts-provider': ttsProvider, + }; + + const reqBody: TTSRequestPayload = { + text: trimmedText, + voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), + speed: voiceSpeed, + format: 'mp3', + model: ttsModel, + instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + }; + + const retryOptions: TTSRetryOptions = { + maxRetries: 2, + initialDelay: 5000, + maxDelay: 10000, + backoffFactor: 2 + }; + const audioBuffer = await withRetry( async () => { // Check for abort before starting TTS request @@ -380,20 +406,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const ttsResponse = await fetch('/api/tts', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, - }, - body: JSON.stringify({ - text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, - format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), + headers: reqHeaders, + body: JSON.stringify(reqBody), signal }); @@ -407,12 +421,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } return buffer; }, - { - maxRetries: 2, - initialDelay: 5000, - maxDelay: 10000, - backoffFactor: 2 - } + retryOptions ); // Get the chapter title from our pre-computed map @@ -556,6 +565,29 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`; // Generate audio with retry logic + const reqHeaders: TTSRequestHeaders = { + 'Content-Type': 'application/json', + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + 'x-tts-provider': ttsProvider, + }; + + const reqBody: TTSRequestPayload = { + text: trimmedText, + voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), + speed: voiceSpeed, + format: 'mp3', + model: ttsModel, + instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + }; + + const retryOptions: TTSRetryOptions = { + maxRetries: 2, + initialDelay: 5000, + maxDelay: 10000, + backoffFactor: 2 + }; + const audioBuffer = await withRetry( async () => { if (signal?.aborted) { @@ -564,20 +596,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const ttsResponse = await fetch('/api/tts', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, - }, - body: JSON.stringify({ - text: trimmedText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, - format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), + headers: reqHeaders, + body: JSON.stringify(reqBody), signal }); @@ -591,12 +611,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } return buffer; }, - { - maxRetries: 2, - initialDelay: 5000, - maxDelay: 10000, - backoffFactor: 2 - } + retryOptions ); if (signal?.aborted) { diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx index 81d83d6..8893859 100644 --- a/src/contexts/HTMLContext.tsx +++ b/src/contexts/HTMLContext.tsx @@ -8,10 +8,8 @@ import { useCallback, useMemo, } from 'react'; -import { getHtmlDocument } from '@/utils/dexie'; +import { getHtmlDocument } from '@/lib/dexie'; import { useTTS } from '@/contexts/TTSContext'; -import { useConfig } from '@/contexts/ConfigContext'; -import { combineAudioChunks, withRetry } from '@/utils/audio'; interface HTMLContextType { currDocData: string | undefined; @@ -19,8 +17,6 @@ interface HTMLContextType { currDocText: string | undefined; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; - createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise; - isAudioCombining: boolean; } const HTMLContext = createContext(undefined); @@ -33,13 +29,12 @@ const HTMLContext = createContext(undefined); */ export function HTMLProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, stop } = useTTS(); - const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider, ttsModel, ttsInstructions } = useConfig(); // Current document state const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); - const [isAudioCombining, setIsAudioCombining] = useState(false); + /** * Clears all current document state and stops any active TTS @@ -73,84 +68,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) { } }, [clearCurrDoc, setTTSText]); - /** - * Creates a complete audiobook from the document text - */ - const createFullAudioBook = useCallback(async ( - onProgress: (progress: number) => void, - signal?: AbortSignal, - format: 'mp3' | 'm4b' = 'mp3' - ): Promise => { - try { - if (!currDocText) { - throw new Error('No text content found in document'); - } - const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; - const currentTime = 0; - - try { - const audioBuffer = await withRetry( - async () => { - const ttsResponse = await fetch('/api/tts', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, - }, - body: JSON.stringify({ - text: currDocText, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, - format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), - 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; - }, - { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 - } - ); - - audioChunks.push({ - buffer: audioBuffer, - title: currDocName, - startTime: currentTime - }); - - onProgress(100); // Single chunk, so we're done when it completes - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - console.log('TTS request aborted'); - const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining); - return partialBuffer; - } - throw error; - } - - return combineAudioChunks(audioChunks, format, setIsAudioCombining); - } catch (error) { - console.error('Error creating audiobook:', error); - throw error; - } - }, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]); const contextValue = useMemo(() => ({ currDocData, @@ -158,16 +76,12 @@ export function HTMLProvider({ children }: { children: ReactNode }) { currDocText, setCurrentDocument, clearCurrDoc, - createFullAudioBook, - isAudioCombining, }), [ currDocData, currDocName, currDocText, setCurrentDocument, clearCurrDoc, - createFullAudioBook, - isAudioCombining, ]); return ( diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 6a53b8f..1702ff1 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -25,19 +25,21 @@ import { useRef, } from 'react'; -import { getPdfDocument } from '@/utils/dexie'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; + +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 { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick, -} from '@/utils/pdf'; -import { processTextToSentences } from '@/utils/nlp'; +} from '@/lib/pdf'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { withRetry } from '@/utils/audio'; +import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts'; /** * Interface defining all available methods and properties in the PDF context @@ -328,6 +330,30 @@ export function PDFProvider({ children }: { children: ReactNode }) { onProgress((processedLength / totalLength) * 100); continue; } + + const reqHeaders: TTSRequestHeaders = { + 'Content-Type': 'application/json', + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + 'x-tts-provider': ttsProvider, + }; + + const reqBody: TTSRequestPayload = { + text, + voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), + speed: voiceSpeed, + format: 'mp3', + model: ttsModel, + instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + }; + + const retryOptions: TTSRetryOptions = { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 5000, + backoffFactor: 2 + }; + try { const audioBuffer = await withRetry( async () => { @@ -338,20 +364,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { const ttsResponse = await fetch('/api/tts', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, - }, - body: JSON.stringify({ - text, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, - format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), + headers: reqHeaders, + body: JSON.stringify(reqBody), signal }); @@ -365,12 +379,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } return buffer; }, - { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 - } + retryOptions ); const chapterTitle = `Page ${i + 1}`; @@ -520,6 +529,29 @@ export function PDFProvider({ children }: { children: ReactNode }) { const chapterTitle = `Page ${chapterIndex + 1}`; // Generate audio with retry logic + const reqHeaders: TTSRequestHeaders = { + 'Content-Type': 'application/json', + 'x-openai-key': apiKey, + 'x-openai-base-url': baseUrl, + 'x-tts-provider': ttsProvider, + }; + + const reqBody: TTSRequestPayload = { + text: textForTTS, + voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), + speed: voiceSpeed, + format: 'mp3', + model: ttsModel, + instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined + }; + + const retryOptions: TTSRetryOptions = { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 5000, + backoffFactor: 2 + }; + const audioBuffer = await withRetry( async () => { if (signal?.aborted) { @@ -528,20 +560,8 @@ export function PDFProvider({ children }: { children: ReactNode }) { const ttsResponse = await fetch('/api/tts', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, - 'x-tts-provider': ttsProvider, - }, - body: JSON.stringify({ - text: textForTTS, - voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')), - speed: voiceSpeed, - format: 'mp3', - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), + headers: reqHeaders, + body: JSON.stringify(reqBody), signal }); @@ -555,12 +575,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } return buffer; }, - { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 - } + retryOptions ); if (signal?.aborted) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 477d81f..9157a70 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -34,11 +34,20 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; -import { getLastDocumentLocation, setLastDocumentLocation } from '@/utils/dexie'; +import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry } from '@/utils/audio'; -import { preprocessSentenceForAudio, processTextToSentences } from '@/utils/nlp'; +import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp'; import { isKokoroModel } from '@/utils/voice'; +import type { + TTSLocation, + ContinuationMergeResult, + PageTurnEstimate, + TTSPlaybackState, + TTSRequestPayload, + TTSRequestHeaders, + TTSRetryOptions, +} from '@/types/tts'; // Media globals declare global { @@ -50,18 +59,7 @@ declare global { /** * Interface defining all available methods and properties in the TTS context */ -interface TTSContextType { - // Playback state - isPlaying: boolean; - isProcessing: boolean; - currentSentence: string; - isBackgrounded: boolean; // Add this new property - - // Navigation - currDocPage: string | number; // Change this to allow both types - currDocPageNumber: number; // For PDF - currDocPages: number | undefined; - +interface TTSContextType extends TTSPlaybackState { // Voice settings availableVoices: string[]; @@ -77,34 +75,23 @@ interface TTSContextType { setSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; - skipToLocation: (location: string | number, shouldPause?: boolean) => void; - registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation - registerVisualPageChangeHandler: (handler: (location: string | number) => void) => void; + skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; + registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation + registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => void; setIsEPUB: (isEPUB: boolean) => void; } interface SetTextOptions { shouldPause?: boolean; - location?: string | number; - nextLocation?: string | number; + location?: TTSLocation; + nextLocation?: TTSLocation; nextText?: string; } -interface ContinuationMergeResult { - text: string; - carried: string; -} - -interface PageTurnEstimate { - location: string | number; - sentenceIndex: number; - fraction: number; -} - const CONTINUATION_LOOKAHEAD = 600; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; -const normalizeLocationKey = (location: string | number) => +const normalizeLocationKey = (location: TTSLocation) => typeof location === 'number' ? `num:${location}` : `str:${location}`; const isWhitespaceChar = (char: string) => /\s/.test(char); @@ -279,14 +266,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement smartSentenceSplitting, } = useConfig(); - // Remove OpenAI client reference as it's no longer needed + // Audio and voice management hooks const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); // Add ref for location change handler - const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null); - const visualPageChangeHandlerRef = useRef<((location: string | number) => void) | null>(null); + const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); + const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); /** * Registers a handler function for location changes in EPUB documents @@ -294,11 +281,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * * @param {Function} handler - Function to handle location changes */ - const registerLocationChangeHandler = useCallback((handler: (location: string | number) => void) => { + const registerLocationChangeHandler = useCallback((handler: (location: TTSLocation) => void) => { locationChangeHandlerRef.current = handler; }, []); - const registerVisualPageChangeHandler = useCallback((handler: (location: string | number) => void) => { + /** + * Registers a handler function for visual page changes in EPUB documents + * This is only used for EPUB documents to handle visual page navigation + * + * @param {Function} handler - Function to handle visual page changes + */ + const registerVisualPageChangeHandler = useCallback((handler: (location: TTSLocation) => void) => { visualPageChangeHandlerRef.current = handler; }, []); @@ -312,7 +305,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [isEPUB, setIsEPUB] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - const [currDocPage, setCurrDocPage] = useState(1); + const [currDocPage, setCurrDocPage] = useState(1); const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only const [currDocPages, setCurrDocPages] = useState(); @@ -395,7 +388,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * @param {string | number} location - The target location to navigate to * @param {boolean} shouldPause - Whether to pause playback */ - const skipToLocation = useCallback((location: string | number, shouldPause = false) => { + const skipToLocation = useCallback((location: TTSLocation, shouldPause = false) => { // Reset state for new content in correct order abortAudio(); if (shouldPause) setIsPlaying(false); @@ -727,23 +720,37 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const controller = new AbortController(); activeAbortControllers.current.add(controller); + const reqHeaders: TTSRequestHeaders = { + 'Content-Type': 'application/json', + 'x-openai-key': openApiKey || '', + 'x-tts-provider': configTTSProvider, + }; + if (openApiBaseUrl) { + reqHeaders['x-openai-base-url'] = openApiBaseUrl; + } + + const reqBody: TTSRequestPayload = { + text: sentence, + voice, + speed, + model: ttsModel, + instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined, + }; + + const retryOptions: TTSRetryOptions = { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 5000, + backoffFactor: 2 + }; + const arrayBuffer = await withRetry( async () => { + const response = await fetch('/api/tts', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-openai-key': openApiKey || '', - 'x-openai-base-url': openApiBaseUrl || '', - 'x-tts-provider': configTTSProvider, - }, - body: JSON.stringify({ - text: sentence, - voice: voice, - speed: speed, - model: ttsModel, - instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined - }), + headers: reqHeaders, + body: JSON.stringify(reqBody), signal: controller.signal, }); @@ -753,12 +760,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return response.arrayBuffer(); }, - { - maxRetries: 3, - initialDelay: 1000, - maxDelay: 5000, - backoffFactor: 2 - } + retryOptions ); // Remove the controller once the request is complete @@ -1319,7 +1321,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement /** * Custom hook to consume the TTS context - * Ensures the context is used within a provider + * Ensures the context is used within a TTSProvider * * @throws {Error} If used outside of TTSProvider * @returns {TTSContextType} The TTS context value diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts index 1098383..d09a170 100644 --- a/src/hooks/epub/useEPUBDocuments.ts +++ b/src/hooks/epub/useEPUBDocuments.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/utils/dexie'; +import { db } from '@/lib/dexie'; import type { EPUBDocument } from '@/types/documents'; export function useEPUBDocuments() { diff --git a/src/hooks/epub/useEPUBResize.ts b/src/hooks/epub/useEPUBResize.ts index f51ae37..aec59bf 100644 --- a/src/hooks/epub/useEPUBResize.ts +++ b/src/hooks/epub/useEPUBResize.ts @@ -1,5 +1,5 @@ import { useEffect, RefObject, useState } from 'react'; -import { debounce } from '@/utils/pdf'; +import { debounce } from '@/lib/pdf'; export function useEPUBResize(containerRef: RefObject) { const [isResizing, setIsResizing] = useState(false); diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts index a51ef9c..593ef5d 100644 --- a/src/hooks/html/useHTMLDocuments.ts +++ b/src/hooks/html/useHTMLDocuments.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/utils/dexie'; +import { db } from '@/lib/dexie'; import type { HTMLDocument } from '@/types/documents'; export function useHTMLDocuments() { diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index 57bb6c5..bfe3425 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/utils/dexie'; +import { db } from '@/lib/dexie'; import type { PDFDocument } from '@/types/documents'; export function usePDFDocuments() { diff --git a/src/hooks/pdf/usePDFResize.ts b/src/hooks/pdf/usePDFResize.ts index 94fa291..d495d4c 100644 --- a/src/hooks/pdf/usePDFResize.ts +++ b/src/hooks/pdf/usePDFResize.ts @@ -1,5 +1,5 @@ import { RefObject, useState, useEffect } from 'react'; -import { debounce } from '@/utils/pdf'; +import { debounce } from '@/lib/pdf'; interface UsePDFResizeResult { containerWidth: number; diff --git a/src/utils/dexie.ts b/src/lib/dexie.ts similarity index 99% rename from src/utils/dexie.ts rename to src/lib/dexie.ts index 8eb1554..ee62a29 100644 --- a/src/utils/dexie.ts +++ b/src/lib/dexie.ts @@ -1,5 +1,5 @@ import Dexie, { type EntityTable } from 'dexie'; -import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig'; +import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents'; const DB_NAME = 'openreader-db'; diff --git a/src/utils/epub.ts b/src/lib/epub.ts similarity index 100% rename from src/utils/epub.ts rename to src/lib/epub.ts diff --git a/src/utils/nlp.ts b/src/lib/nlp.ts similarity index 100% rename from src/utils/nlp.ts rename to src/lib/nlp.ts diff --git a/src/utils/pdf.ts b/src/lib/pdf.ts similarity index 99% rename from src/utils/pdf.ts rename to src/lib/pdf.ts index 3ae3a88..cfcb945 100644 --- a/src/utils/pdf.ts +++ b/src/lib/pdf.ts @@ -2,7 +2,7 @@ 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 '@/utils/nlp'; +import { processTextToSentences } from '@/lib/nlp'; import { CmpStr } from 'cmpstr'; const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' ); diff --git a/src/utils/pdfHighlightWorker.ts b/src/lib/pdfHighlightWorker.ts similarity index 87% rename from src/utils/pdfHighlightWorker.ts rename to src/lib/pdfHighlightWorker.ts index ac00223..5b41d45 100644 --- a/src/utils/pdfHighlightWorker.ts +++ b/src/lib/pdfHighlightWorker.ts @@ -20,6 +20,21 @@ interface TokenMatchResponse { lengthDiff: number; } +/* + Token Matching Worker + + This worker receives a pattern string and an array of token texts, + and attempts to find the best matching contiguous sequence of tokens + that aligns with the pattern. + + It uses the Dice coefficient string similarity metric to evaluate + how closely different token windows match the pattern, adjusting + for length differences and applying a prefix-alignment boost. + + The worker responds with the start and end indices of the best matching + token window, along with its similarity rating and length difference. +*/ + self.onmessage = (event: MessageEvent) => { const data = event.data; if (!data || data.type !== 'tokenMatch') return; diff --git a/src/types/appConfig.ts b/src/types/config.ts similarity index 88% rename from src/types/appConfig.ts rename to src/types/config.ts index c34fb3b..7d6c046 100644 --- a/src/types/appConfig.ts +++ b/src/types/config.ts @@ -4,7 +4,7 @@ export type ViewType = 'single' | 'dual' | 'scroll'; export type SavedVoices = Record; -export interface AppConfigDefaults { +export interface AppConfigValues { apiKey: string; baseUrl: string; viewType: ViewType; @@ -27,7 +27,7 @@ export interface AppConfigDefaults { documentListState: DocumentListState; } -export const APP_CONFIG_DEFAULTS: AppConfigDefaults = { +export const APP_CONFIG_DEFAULTS: AppConfigValues = { apiKey: '', baseUrl: '', viewType: 'single', @@ -56,6 +56,6 @@ export const APP_CONFIG_DEFAULTS: AppConfigDefaults = { }, }; -export interface AppConfigRow extends AppConfigDefaults { +export interface AppConfigRow extends AppConfigValues { id: string; } diff --git a/src/types/tts.ts b/src/types/tts.ts new file mode 100644 index 0000000..bd0fa8c --- /dev/null +++ b/src/types/tts.ts @@ -0,0 +1,63 @@ +export type TTSLocation = string | number; + +// Result of merging a continuation slice into the current text +export interface ContinuationMergeResult { + text: string; + carried: string; +} + +// Estimate for when a visual page/section turn should occur during audio playback +export interface PageTurnEstimate { + location: TTSLocation; + sentenceIndex: number; + fraction: number; +} + +// Standardized error codes for the TTS API +export type TTSErrorCode = + | 'MISSING_PARAMETERS' + | 'INVALID_REQUEST' + | 'TTS_GENERATION_FAILED' + | 'ABORTED' + | 'INTERNAL_ERROR'; + +// Structured error object returned by the TTS API +export interface TTSError { + code: TTSErrorCode; + message: string; + 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; + isProcessing: boolean; + isBackgrounded: boolean; + currentSentence: string; + currDocPage: TTSLocation; + currDocPageNumber: number; + currDocPages?: number; +} + +export interface TTSRetryOptions { + maxRetries?: number; + initialDelay?: number; + maxDelay?: number; + backoffFactor?: number; +} \ No newline at end of file diff --git a/src/utils/audio.ts b/src/utils/audio.ts index 69034c4..db46302 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -1,19 +1,4 @@ -/** - * Utility functions for audio processing - */ - -interface AudioChunk { - buffer: ArrayBuffer; - title?: string; - startTime: number; -} - -interface RetryOptions { - maxRetries?: number; - initialDelay?: number; - maxDelay?: number; - backoffFactor?: number; -} +import type { TTSRetryOptions } from '@/types/tts'; /** * Executes a function with exponential backoff retry logic @@ -23,7 +8,7 @@ interface RetryOptions { */ export const withRetry = async ( operation: () => Promise, - options: RetryOptions = {} + options: TTSRetryOptions = {} ): Promise => { const { maxRetries = 3, @@ -61,79 +46,4 @@ export const withRetry = async ( } throw lastError || new Error('Operation failed after retries'); -} - -/** - * Combines audio chunks into a single audio file - * @param audioChunks Array of audio chunks with metadata - * @param format Output format ('mp3' or 'm4b') - * @param setIsAudioCombining Optional callback to track combining state - * @returns Promise resolving to the combined audio buffer - */ -export const combineAudioChunks = async ( - audioChunks: AudioChunk[], - format: 'mp3' | 'm4b', - setIsAudioCombining?: (state: boolean) => void -): Promise => { - if (setIsAudioCombining) { - setIsAudioCombining(true); - } - - try { - if (format === 'm4b') { - // Filter out chunks without titles and silence buffers - const titledChunks = audioChunks.filter(chunk => chunk.title && chunk.buffer.byteLength > 48000); - - let bookId: string | undefined; - - // Upload each chunk sequentially and get book ID - for (const chunk of titledChunks) { - const response = await fetch('/api/audio/convert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chapterTitle: chunk.title, - buffer: Array.from(new Uint8Array(chunk.buffer)), - bookId, // Will be undefined for first chunk, then set for subsequent ones - format - }), - }); - - if (!response.ok) { - throw new Error('Failed to upload audio chunk'); - } - - const result = await response.json(); - bookId = result.bookId; // Save book ID for subsequent chunks - } - - if (!bookId) { - throw new Error('No book ID received from server'); - } - - // Get the final combined M4B/MP3 file - const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`); - if (!m4bResponse.ok) { - throw new Error('Failed to get combined M4B'); - } - - return await m4bResponse.arrayBuffer(); - } - - // For MP3, just concatenate the buffers - const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); - const combinedBuffer = new Uint8Array(totalLength); - - let offset = 0; - for (const chunk of audioChunks) { - combinedBuffer.set(new Uint8Array(chunk.buffer), offset); - offset += chunk.buffer.byteLength; - } - - return combinedBuffer.buffer; - } finally { - if (setIsAudioCombining) setIsAudioCombining(false); - } -} +}; \ No newline at end of file diff --git a/src/utils/voice.ts b/src/utils/voice.ts index bdd34ce..ba1852d 100644 --- a/src/utils/voice.ts +++ b/src/utils/voice.ts @@ -46,16 +46,6 @@ export const isKokoroModel = (modelName: string | undefined): boolean => { return (modelName || '').toLowerCase().includes('kokoro'); }; -/** - * Strips weight annotations from a voice string - * - * @param voiceString - Voice string with or without weights - * @returns Voice string without weights - */ -export const stripVoiceWeights = (voiceString: string): string => { - return voiceString.replace(/\([^)]*\)/g, '').trim(); -}; - /** * Determines the maximum number of voices allowed for a provider/model combination *