refactor(core): standardize module structure and enhance TTS types

- Reorganized utility modules from `src/utils` to `src/lib` for clearer separation of concerns.
- Introduced new, dedicated type definitions in `src/types` for improved type safety in configuration and TTS API interactions.
  - Replaced `src/types/appConfig.ts` with `src/types/config.ts`.
  - Added `src/types/tts.ts` for TTS request payloads, error structures, and retry options.
- Updated module imports across several contexts (`Config`, `EPUB`, `HTML`, `PDF`, `TTS`) and components to reflect the new `lib` and `types` locations.
- Enhanced TTS API request and error handling in `src/app/api/tts/route.ts` and TTS-consuming contexts with explicit types.
- Simplified `ProgressCard`, `ProgressPopup`, and `AudiobookExportModal` components by removing the `isProcessing` prop, centralizing processing state management.
- Streamlined `HTMLContext` by removing `createFullAudioBook` and `isAudioCombining` properties, focusing its scope.
This commit is contained in:
Richard Roberson 2025-11-16 15:12:35 -07:00
parent ad4faa3ddc
commit 04def62ff5
26 changed files with 309 additions and 388 deletions

View file

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

View file

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

View file

@ -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<number> || [];
const pageNum = destArray[0] + 1 || null;
if (pageNum) {
skipToLocation(pageNum, true);
}

View file

@ -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 && (
<span className="w-3 h-3">
<LoadingSpinner />
</span>
)}
<span>{cancelText}</span>
</button>
</div>

View file

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

View file

@ -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"

View file

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

View file

@ -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<AppConfigRow, 'id'>;
/** 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<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
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 <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
const updateConfigKey = async <K extends keyof AppConfigValues>(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<AppConfigRow>);
}
@ -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<AppConfigRow>);
}
} catch (error) {

View file

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

View file

@ -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<void>;
clearCurrDoc: () => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
isAudioCombining: boolean;
}
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
@ -33,13 +29,12 @@ const HTMLContext = createContext<HTMLContextType | undefined>(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<string>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
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<ArrayBuffer> => {
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 (

View file

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

View file

@ -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<string | number>(1);
const [currDocPage, setCurrDocPage] = useState<TTSLocation>(1);
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
const [currDocPages, setCurrDocPages] = useState<number>();
@ -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

View file

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

View file

@ -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<HTMLDivElement | null>) {
const [isResizing, setIsResizing] = useState(false);

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { RefObject, useState, useEffect } from 'react';
import { debounce } from '@/utils/pdf';
import { debounce } from '@/lib/pdf';
interface UsePDFResizeResult {
containerWidth: number;

View file

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

View file

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

View file

@ -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<TokenMatchRequest>) => {
const data = event.data;
if (!data || data.type !== 'tokenMatch') return;

View file

@ -4,7 +4,7 @@ export type ViewType = 'single' | 'dual' | 'scroll';
export type SavedVoices = Record<string, string>;
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;
}

63
src/types/tts.ts Normal file
View file

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

View file

@ -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 <T>(
operation: () => Promise<T>,
options: RetryOptions = {}
options: TTSRetryOptions = {}
): Promise<T> => {
const {
maxRetries = 3,
@ -61,79 +46,4 @@ export const withRetry = async <T>(
}
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<ArrayBuffer> => {
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);
}
}
};

View file

@ -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
*