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 { isKokoroModel } from '@/utils/voice';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import type { TTSRequestPayload, TTSError } from '@/types/tts';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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 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 openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
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) });
|
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
|
||||||
|
|
||||||
if (!text || !voice || !speed) {
|
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
|
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
|
||||||
const model = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
|
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)
|
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
|
|
@ -118,7 +125,7 @@ export async function POST(req: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizedVoice = (
|
const normalizedVoice = (
|
||||||
!isKokoroModel(model) && voice.includes('+')
|
!isKokoroModel(model as string) && voice.includes('+')
|
||||||
? (voice.split('+')[0].trim())
|
? (voice.split('+')[0].trim())
|
||||||
: voice
|
: voice
|
||||||
) as SpeechCreateParams['voice'];
|
) as SpeechCreateParams['voice'];
|
||||||
|
|
@ -131,7 +138,7 @@ export async function POST(req: NextRequest) {
|
||||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
response_format: format === 'aac' ? 'aac' : 'mp3',
|
||||||
};
|
};
|
||||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
// 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;
|
createParams.instructions = instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,8 +270,13 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('Error generating TTS:', error);
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to generate audio' },
|
errorBody,
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,6 @@ export function AudiobookExportModal({
|
||||||
progress={progress}
|
progress={progress}
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
isProcessing={isCombining}
|
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
operationType="audiobook"
|
operationType="audiobook"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
|
|
@ -531,7 +530,6 @@ export function AudiobookExportModal({
|
||||||
progress={progress}
|
progress={progress}
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
isProcessing={isCombining}
|
|
||||||
operationType="audiobook"
|
operationType="audiobook"
|
||||||
currentChapter={currentChapter}
|
currentChapter={currentChapter}
|
||||||
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
|
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { Document, Page } from 'react-pdf';
|
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/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.css';
|
import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
|
|
@ -14,9 +15,9 @@ interface PDFViewerProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OnItemClickArgs {
|
interface PDFOnLinkClickArgs {
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
dest?: unknown;
|
dest?: Dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
|
|
@ -126,12 +127,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
onLoadSuccess={(pdf) => {
|
onLoadSuccess={(pdf) => {
|
||||||
onDocumentLoadSuccess(pdf);
|
onDocumentLoadSuccess(pdf);
|
||||||
}}
|
}}
|
||||||
onItemClick={(args: OnItemClickArgs) => {
|
onItemClick={(args: PDFOnLinkClickArgs) => {
|
||||||
if (args?.pageNumber) {
|
if (args?.pageNumber) {
|
||||||
skipToLocation(args.pageNumber, true);
|
skipToLocation(args.pageNumber, true);
|
||||||
} else if (args?.dest) {
|
} else if (args?.dest) {
|
||||||
const destArray = Array.isArray(args.dest) ? args.dest : [];
|
const destArray = args.dest as Array<number> || [];
|
||||||
const pageNum = typeof destArray[0] === 'number' ? destArray[0] + 1 : undefined;
|
const pageNum = destArray[0] + 1 || null;
|
||||||
if (pageNum) {
|
if (pageNum) {
|
||||||
skipToLocation(pageNum, true);
|
skipToLocation(pageNum, true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { LoadingSpinner } from './Spinner';
|
|
||||||
|
|
||||||
interface ProgressCardProps {
|
interface ProgressCardProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
estimatedTimeRemaining?: string;
|
estimatedTimeRemaining?: string;
|
||||||
onCancel: (e?: React.MouseEvent) => void;
|
onCancel: (e?: React.MouseEvent) => void;
|
||||||
isProcessing?: boolean;
|
|
||||||
operationType?: 'sync' | 'load' | 'audiobook';
|
operationType?: 'sync' | 'load' | 'audiobook';
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
currentChapter?: string;
|
currentChapter?: string;
|
||||||
|
|
@ -16,7 +13,6 @@ export function ProgressCard({
|
||||||
progress,
|
progress,
|
||||||
estimatedTimeRemaining,
|
estimatedTimeRemaining,
|
||||||
onCancel,
|
onCancel,
|
||||||
isProcessing = false,
|
|
||||||
operationType,
|
operationType,
|
||||||
cancelText = 'Cancel',
|
cancelText = 'Cancel',
|
||||||
currentChapter,
|
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"
|
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)}
|
onClick={(e) => onCancel(e)}
|
||||||
>
|
>
|
||||||
{isProcessing && (
|
|
||||||
<span className="w-3 h-3">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{cancelText}</span>
|
<span>{cancelText}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ interface ProgressPopupProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
estimatedTimeRemaining?: string;
|
estimatedTimeRemaining?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isProcessing: boolean;
|
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
operationType?: 'sync' | 'load' | 'audiobook';
|
operationType?: 'sync' | 'load' | 'audiobook';
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
|
@ -22,7 +21,6 @@ export function ProgressPopup({
|
||||||
progress,
|
progress,
|
||||||
estimatedTimeRemaining,
|
estimatedTimeRemaining,
|
||||||
onCancel,
|
onCancel,
|
||||||
isProcessing,
|
|
||||||
statusMessage,
|
statusMessage,
|
||||||
operationType,
|
operationType,
|
||||||
cancelText = 'Cancel',
|
cancelText = 'Cancel',
|
||||||
|
|
@ -56,7 +54,6 @@ export function ProgressPopup({
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
onCancel();
|
onCancel();
|
||||||
}}
|
}}
|
||||||
isProcessing={isProcessing}
|
|
||||||
operationType={operationType}
|
operationType={operationType}
|
||||||
cancelText={cancelText}
|
cancelText={cancelText}
|
||||||
currentChapter={currentChapter}
|
currentChapter={currentChapter}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
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 { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
@ -699,7 +699,6 @@ export function SettingsModal() {
|
||||||
setOperationType('sync');
|
setOperationType('sync');
|
||||||
setAbortController(null);
|
setAbortController(null);
|
||||||
}}
|
}}
|
||||||
isProcessing={isSyncing || isLoading}
|
|
||||||
statusMessage={statusMessage}
|
statusMessage={statusMessage}
|
||||||
operationType={operationType}
|
operationType={operationType}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
|
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 { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
||||||
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db, initDB, updateAppConfig } from '@/utils/dexie';
|
import { db, initDB, updateAppConfig } from '@/lib/dexie';
|
||||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig';
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||||
export type { ViewType } from '@/types/appConfig';
|
export type { ViewType } from '@/types/config';
|
||||||
|
|
||||||
/** Configuration values for the application */
|
/** Configuration values for the application */
|
||||||
type ConfigValues = Omit<AppConfigRow, 'id'>;
|
|
||||||
|
|
||||||
/** Interface defining the configuration context shape and functionality */
|
/** Interface defining the configuration context shape and functionality */
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
|
|
@ -29,7 +28,7 @@ interface ConfigContextType {
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
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;
|
isLoading: boolean;
|
||||||
isDBReady: boolean;
|
isDBReady: boolean;
|
||||||
pdfHighlightEnabled: boolean;
|
pdfHighlightEnabled: boolean;
|
||||||
|
|
@ -76,7 +75,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const config: ConfigValues | null = useMemo(() => {
|
const config: AppConfigValues | null = useMemo(() => {
|
||||||
if (!appConfig) return null;
|
if (!appConfig) return null;
|
||||||
const { id, ...rest } = appConfig;
|
const { id, ...rest } = appConfig;
|
||||||
void id;
|
void id;
|
||||||
|
|
@ -131,9 +130,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
/**
|
/**
|
||||||
* Updates a single configuration value by key
|
* Updates a single configuration value by key
|
||||||
* @param {K} key - The configuration key to update
|
* @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 {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
|
@ -153,7 +152,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||||
const restoredVoice = savedVoices[voiceKey] || '';
|
const restoredVoice = savedVoices[voiceKey] || '';
|
||||||
await updateAppConfig({
|
await updateAppConfig({
|
||||||
[key]: value as ConfigValues[keyof ConfigValues],
|
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||||
voice: restoredVoice,
|
voice: restoredVoice,
|
||||||
} as Partial<AppConfigRow>);
|
} as Partial<AppConfigRow>);
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await updateAppConfig({
|
await updateAppConfig({
|
||||||
[key]: value as ConfigValues[keyof ConfigValues],
|
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||||
} as Partial<AppConfigRow>);
|
} as Partial<AppConfigRow>);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
RefObject
|
RefObject
|
||||||
} from 'react';
|
} 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 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 { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
|
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -371,6 +374,29 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
// Check for abort before starting TTS request
|
// Check for abort before starting TTS request
|
||||||
|
|
@ -380,20 +406,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'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
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -407,12 +421,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 2,
|
|
||||||
initialDelay: 5000,
|
|
||||||
maxDelay: 10000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the chapter title from our pre-computed map
|
// 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}`;
|
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
|
||||||
|
|
||||||
// Generate audio with retry logic
|
// 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(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
@ -564,20 +596,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'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
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -591,12 +611,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 2,
|
|
||||||
initialDelay: 5000,
|
|
||||||
maxDelay: 10000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { getHtmlDocument } from '@/utils/dexie';
|
import { getHtmlDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
|
||||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
|
||||||
|
|
||||||
interface HTMLContextType {
|
interface HTMLContextType {
|
||||||
currDocData: string | undefined;
|
currDocData: string | undefined;
|
||||||
|
|
@ -19,8 +17,6 @@ interface HTMLContextType {
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
|
||||||
isAudioCombining: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||||
|
|
@ -33,13 +29,12 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||||
*/
|
*/
|
||||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
const { setText: setTTSText, stop } = useTTS();
|
const { setText: setTTSText, stop } = useTTS();
|
||||||
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider, ttsModel, ttsInstructions } = useConfig();
|
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<string>();
|
const [currDocData, setCurrDocData] = useState<string>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all current document state and stops any active TTS
|
* Clears all current document state and stops any active TTS
|
||||||
|
|
@ -73,84 +68,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [clearCurrDoc, setTTSText]);
|
}, [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(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
currDocData,
|
currDocData,
|
||||||
|
|
@ -158,16 +76,12 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
createFullAudioBook,
|
|
||||||
isAudioCombining,
|
|
||||||
}), [
|
}), [
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
currDocText,
|
currDocText,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
createFullAudioBook,
|
|
||||||
isAudioCombining,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,21 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { getPdfDocument } from '@/utils/dexie';
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
import { getPdfDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import { processTextToSentences } from '@/lib/nlp';
|
||||||
|
import { withRetry } from '@/utils/audio';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
} from '@/utils/pdf';
|
} from '@/lib/pdf';
|
||||||
import { processTextToSentences } from '@/utils/nlp';
|
|
||||||
|
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||||
import { withRetry } from '@/utils/audio';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* 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);
|
onProgress((processedLength / totalLength) * 100);
|
||||||
continue;
|
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 {
|
try {
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
@ -338,20 +364,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'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
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -365,12 +379,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const chapterTitle = `Page ${i + 1}`;
|
const chapterTitle = `Page ${i + 1}`;
|
||||||
|
|
@ -520,6 +529,29 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const chapterTitle = `Page ${chapterIndex + 1}`;
|
const chapterTitle = `Page ${chapterIndex + 1}`;
|
||||||
|
|
||||||
// Generate audio with retry logic
|
// 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(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
@ -528,20 +560,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'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
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -555,12 +575,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,20 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache';
|
||||||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||||||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
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 { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
import { preprocessSentenceForAudio, processTextToSentences } from '@/utils/nlp';
|
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||||
import { isKokoroModel } from '@/utils/voice';
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
|
import type {
|
||||||
|
TTSLocation,
|
||||||
|
ContinuationMergeResult,
|
||||||
|
PageTurnEstimate,
|
||||||
|
TTSPlaybackState,
|
||||||
|
TTSRequestPayload,
|
||||||
|
TTSRequestHeaders,
|
||||||
|
TTSRetryOptions,
|
||||||
|
} from '@/types/tts';
|
||||||
|
|
||||||
// Media globals
|
// Media globals
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -50,18 +59,7 @@ declare global {
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the TTS context
|
* Interface defining all available methods and properties in the TTS context
|
||||||
*/
|
*/
|
||||||
interface TTSContextType {
|
interface TTSContextType extends TTSPlaybackState {
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Voice settings
|
// Voice settings
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
|
|
||||||
|
|
@ -77,34 +75,23 @@ interface TTSContextType {
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation
|
||||||
registerVisualPageChangeHandler: (handler: (location: string | number) => void) => void;
|
registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => void;
|
||||||
setIsEPUB: (isEPUB: boolean) => void;
|
setIsEPUB: (isEPUB: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetTextOptions {
|
interface SetTextOptions {
|
||||||
shouldPause?: boolean;
|
shouldPause?: boolean;
|
||||||
location?: string | number;
|
location?: TTSLocation;
|
||||||
nextLocation?: string | number;
|
nextLocation?: TTSLocation;
|
||||||
nextText?: string;
|
nextText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContinuationMergeResult {
|
|
||||||
text: string;
|
|
||||||
carried: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageTurnEstimate {
|
|
||||||
location: string | number;
|
|
||||||
sentenceIndex: number;
|
|
||||||
fraction: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTINUATION_LOOKAHEAD = 600;
|
const CONTINUATION_LOOKAHEAD = 600;
|
||||||
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
||||||
|
|
||||||
const normalizeLocationKey = (location: string | number) =>
|
const normalizeLocationKey = (location: TTSLocation) =>
|
||||||
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||||||
|
|
||||||
const isWhitespaceChar = (char: string) => /\s/.test(char);
|
const isWhitespaceChar = (char: string) => /\s/.test(char);
|
||||||
|
|
@ -279,14 +266,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Remove OpenAI client reference as it's no longer needed
|
// Audio and voice management hooks
|
||||||
const audioContext = useAudioContext();
|
const audioContext = useAudioContext();
|
||||||
const audioCache = useAudioCache(25);
|
const audioCache = useAudioCache(25);
|
||||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||||
|
|
||||||
// Add ref for location change handler
|
// Add ref for location change handler
|
||||||
const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
|
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||||
const visualPageChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
|
const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a handler function for location changes in EPUB documents
|
* 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
|
* @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;
|
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;
|
visualPageChangeHandlerRef.current = handler;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -312,7 +305,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const [isEPUB, setIsEPUB] = useState(false);
|
const [isEPUB, setIsEPUB] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = 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 currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
||||||
const [currDocPages, setCurrDocPages] = useState<number>();
|
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 {string | number} location - The target location to navigate to
|
||||||
* @param {boolean} shouldPause - Whether to pause playback
|
* @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
|
// Reset state for new content in correct order
|
||||||
abortAudio();
|
abortAudio();
|
||||||
if (shouldPause) setIsPlaying(false);
|
if (shouldPause) setIsPlaying(false);
|
||||||
|
|
@ -727,23 +720,37 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
activeAbortControllers.current.add(controller);
|
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(
|
const arrayBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
||||||
const response = await fetch('/api/tts', {
|
const response = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'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
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -753,12 +760,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
return response.arrayBuffer();
|
return response.arrayBuffer();
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove the controller once the request is complete
|
// 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
|
* 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
|
* @throws {Error} If used outside of TTSProvider
|
||||||
* @returns {TTSContextType} The TTS context value
|
* @returns {TTSContextType} The TTS context value
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/utils/dexie';
|
import { db } from '@/lib/dexie';
|
||||||
import type { EPUBDocument } from '@/types/documents';
|
import type { EPUBDocument } from '@/types/documents';
|
||||||
|
|
||||||
export function useEPUBDocuments() {
|
export function useEPUBDocuments() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, RefObject, useState } from 'react';
|
import { useEffect, RefObject, useState } from 'react';
|
||||||
import { debounce } from '@/utils/pdf';
|
import { debounce } from '@/lib/pdf';
|
||||||
|
|
||||||
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
|
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/utils/dexie';
|
import { db } from '@/lib/dexie';
|
||||||
import type { HTMLDocument } from '@/types/documents';
|
import type { HTMLDocument } from '@/types/documents';
|
||||||
|
|
||||||
export function useHTMLDocuments() {
|
export function useHTMLDocuments() {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/utils/dexie';
|
import { db } from '@/lib/dexie';
|
||||||
import type { PDFDocument } from '@/types/documents';
|
import type { PDFDocument } from '@/types/documents';
|
||||||
|
|
||||||
export function usePDFDocuments() {
|
export function usePDFDocuments() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { RefObject, useState, useEffect } from 'react';
|
import { RefObject, useState, useEffect } from 'react';
|
||||||
import { debounce } from '@/utils/pdf';
|
import { debounce } from '@/lib/pdf';
|
||||||
|
|
||||||
interface UsePDFResizeResult {
|
interface UsePDFResizeResult {
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Dexie, { type EntityTable } from 'dexie';
|
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';
|
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
|
||||||
|
|
||||||
const DB_NAME = 'openreader-db';
|
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 { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
||||||
import "core-js/proposals/promise-with-resolvers";
|
import "core-js/proposals/promise-with-resolvers";
|
||||||
import { processTextToSentences } from '@/utils/nlp';
|
import { processTextToSentences } from '@/lib/nlp';
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' );
|
const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' );
|
||||||
|
|
@ -20,6 +20,21 @@ interface TokenMatchResponse {
|
||||||
lengthDiff: number;
|
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>) => {
|
self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.type !== 'tokenMatch') return;
|
if (!data || data.type !== 'tokenMatch') return;
|
||||||
|
|
@ -4,7 +4,7 @@ export type ViewType = 'single' | 'dual' | 'scroll';
|
||||||
|
|
||||||
export type SavedVoices = Record<string, string>;
|
export type SavedVoices = Record<string, string>;
|
||||||
|
|
||||||
export interface AppConfigDefaults {
|
export interface AppConfigValues {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
viewType: ViewType;
|
viewType: ViewType;
|
||||||
|
|
@ -27,7 +27,7 @@ export interface AppConfigDefaults {
|
||||||
documentListState: DocumentListState;
|
documentListState: DocumentListState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfigDefaults = {
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
viewType: 'single',
|
viewType: 'single',
|
||||||
|
|
@ -56,6 +56,6 @@ export const APP_CONFIG_DEFAULTS: AppConfigDefaults = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AppConfigRow extends AppConfigDefaults {
|
export interface AppConfigRow extends AppConfigValues {
|
||||||
id: string;
|
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 @@
|
||||||
/**
|
import type { TTSRetryOptions } from '@/types/tts';
|
||||||
* Utility functions for audio processing
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface AudioChunk {
|
|
||||||
buffer: ArrayBuffer;
|
|
||||||
title?: string;
|
|
||||||
startTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RetryOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
initialDelay?: number;
|
|
||||||
maxDelay?: number;
|
|
||||||
backoffFactor?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function with exponential backoff retry logic
|
* Executes a function with exponential backoff retry logic
|
||||||
|
|
@ -23,7 +8,7 @@ interface RetryOptions {
|
||||||
*/
|
*/
|
||||||
export const withRetry = async <T>(
|
export const withRetry = async <T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
options: RetryOptions = {}
|
options: TTSRetryOptions = {}
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const {
|
const {
|
||||||
maxRetries = 3,
|
maxRetries = 3,
|
||||||
|
|
@ -61,79 +46,4 @@ export const withRetry = async <T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError || new Error('Operation failed after retries');
|
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');
|
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
|
* Determines the maximum number of voices allowed for a provider/model combination
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue