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:
parent
ad4faa3ddc
commit
04def62ff5
26 changed files with 309 additions and 388 deletions
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { RefObject, useState, useEffect } from 'react';
|
||||
import { debounce } from '@/utils/pdf';
|
||||
import { debounce } from '@/lib/pdf';
|
||||
|
||||
interface UsePDFResizeResult {
|
||||
containerWidth: number;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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' );
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
63
src/types/tts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue