refactor(client): centralize client-side API calls and refine types
Abstracted direct fetch calls across components and contexts into new functions within `src/lib/client.ts`. This provides a consistent and centralized interface for interacting with backend APIs. - Introduced `src/lib/client.ts` to encapsulate API request logic. - Standardized audio buffer types (`TTSAudioBuffer`, `TTSAudioBytes`) in `src/types/tts.ts`. - Moved client-specific request types (`TTSRequestPayload`, `TTSRequestHeaders`, `TTSRetryOptions`) to `src/types/client.ts`. - Updated API routes and consumer components/contexts to leverage the new client library functions and type definitions. - Removed `src/utils/audio.ts` as its utility functions are now part of `src/lib/client.ts`.
This commit is contained in:
parent
7a29f73d07
commit
b576910523
20 changed files with 455 additions and 439 deletions
|
|
@ -4,12 +4,13 @@ import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises';
|
||||||
import { existsSync, createReadStream } from 'fs';
|
import { existsSync, createReadStream } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
interface ConversionRequest {
|
interface ConversionRequest {
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
buffer: number[];
|
buffer: TTSAudioBytes;
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
format?: 'mp3' | 'm4b';
|
format?: TTSAudiobookFormat;
|
||||||
chapterIndex?: number;
|
chapterIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,9 +207,12 @@ export async function POST(request: NextRequest) {
|
||||||
await unlink(inputPath).catch(console.error);
|
await unlink(inputPath).catch(console.error);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: data.chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed' as const,
|
||||||
bookId,
|
bookId,
|
||||||
chapterIndex,
|
format
|
||||||
duration
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -229,7 +233,7 @@ export async function POST(request: NextRequest) {
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null;
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readdir, readFile } from 'fs/promises';
|
import { readdir, readFile } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,7 +27,7 @@ export async function GET(request: NextRequest) {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
status: 'completed' | 'error';
|
status: 'completed' | 'error';
|
||||||
bookId: string;
|
bookId: string;
|
||||||
format?: 'mp3' | 'm4b';
|
format?: TTSAudiobookFormat;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const metaFile of metaFiles) {
|
for (const metaFile of metaFiles) {
|
||||||
|
|
@ -68,5 +69,3 @@ export async function GET(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ 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';
|
import type { TTSRequestPayload } from '@/types/client';
|
||||||
|
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -13,7 +14,7 @@ type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
};
|
};
|
||||||
type AudioBufferValue = ArrayBuffer;
|
type AudioBufferValue = TTSAudioBuffer;
|
||||||
|
|
||||||
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
||||||
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
||||||
|
|
@ -25,7 +26,7 @@ const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
|
||||||
});
|
});
|
||||||
|
|
||||||
type InflightEntry = {
|
type InflightEntry = {
|
||||||
promise: Promise<ArrayBuffer>;
|
promise: Promise<TTSAudioBuffer>;
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
consumers: number;
|
consumers: number;
|
||||||
};
|
};
|
||||||
|
|
@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry(
|
||||||
openai: OpenAI,
|
openai: OpenAI,
|
||||||
createParams: ExtendedSpeechParams,
|
createParams: ExtendedSpeechParams,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<TTSAudioBuffer> {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||||
|
|
@ -135,7 +136,7 @@ export async function POST(req: NextRequest) {
|
||||||
voice: normalizedVoice,
|
voice: normalizedVoice,
|
||||||
input: text,
|
input: text,
|
||||||
speed: speed,
|
speed: speed,
|
||||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
response_format: format,
|
||||||
};
|
};
|
||||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||||
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
||||||
|
|
@ -143,7 +144,7 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute cache key and check LRU before making provider call
|
// Compute cache key and check LRU before making provider call
|
||||||
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
|
const contentType = 'audio/mpeg';
|
||||||
|
|
||||||
// Preserve voice string as-is for cache key (no weight stripping)
|
// Preserve voice string as-is for cache key (no weight stripping)
|
||||||
const voiceForKey = typeof createParams.voice === 'string'
|
const voiceForKey = typeof createParams.voice === 'string'
|
||||||
|
|
@ -245,7 +246,7 @@ export async function POST(req: NextRequest) {
|
||||||
};
|
};
|
||||||
req.signal.addEventListener('abort', onAbort, { once: true });
|
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
let buffer: ArrayBuffer;
|
let buffer: TTSAudioBuffer;
|
||||||
try {
|
try {
|
||||||
buffer = await entry.promise;
|
buffer = await entry.promise;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts';
|
||||||
import { preprocessSentenceForAudio } from '@/lib/nlp';
|
import { preprocessSentenceForAudio } from '@/lib/nlp';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
interface WhisperRequestBody {
|
interface WhisperRequestBody {
|
||||||
text: string;
|
text: string;
|
||||||
audio: number[]; // raw bytes from Uint8Array
|
audio: TTSAudioBytes; // raw bytes from Uint8Array
|
||||||
lang?: string;
|
lang?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,7 +331,7 @@ function mapWordsToSentenceOffsets(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function alignAudioWithText(
|
async function alignAudioWithText(
|
||||||
audioBuffer: ArrayBuffer,
|
audioBuffer: TTSAudioBuffer,
|
||||||
text: string,
|
text: string,
|
||||||
cacheKey?: string,
|
cacheKey?: string,
|
||||||
opts: WhisperAlignmentOptions = {}
|
opts: WhisperAlignmentOptions = {}
|
||||||
|
|
@ -448,4 +448,3 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { DownloadIcon } from '@/components/icons/Icons';
|
import { DownloadIcon } from '@/components/icons/Icons';
|
||||||
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
|
@ -86,8 +87,8 @@ export default function EPUBPage() {
|
||||||
const handleGenerateAudiobook = useCallback(async (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
}, [createEPUBAudioBook, id]);
|
}, [createEPUBAudioBook, id]);
|
||||||
|
|
@ -95,7 +96,7 @@ export default function EPUBPage() {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
@ -80,8 +81,8 @@ export default function PDFViewerPage() {
|
||||||
const handleGenerateAudiobook = useCallback(async (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
}, [createPDFAudioBook, id]);
|
}, [createPDFAudioBook, id]);
|
||||||
|
|
@ -89,7 +90,7 @@ export default function PDFViewerPage() {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
import {
|
||||||
|
getAudiobookStatus,
|
||||||
|
deleteAudiobookChapter,
|
||||||
|
deleteAudiobook,
|
||||||
|
downloadAudiobookChapter,
|
||||||
|
downloadAudiobook
|
||||||
|
} from '@/lib/client';
|
||||||
interface AudiobookExportModalProps {
|
interface AudiobookExportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
|
@ -19,12 +26,12 @@ interface AudiobookExportModalProps {
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => Promise<string>; // Returns bookId
|
) => Promise<string>; // Returns bookId
|
||||||
onRegenerateChapter?: (
|
onRegenerateChapter?: (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => Promise<TTSAudiobookChapter>;
|
) => Promise<TTSAudiobookChapter>;
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +53,7 @@ export function AudiobookExportModal({
|
||||||
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||||
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
||||||
const [currentChapter, setCurrentChapter] = useState<string>('');
|
const [currentChapter, setCurrentChapter] = useState<string>('');
|
||||||
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
const [format, setFormat] = useState<TTSAudiobookFormat>('m4b');
|
||||||
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
||||||
|
|
@ -61,26 +68,23 @@ export function AudiobookExportModal({
|
||||||
setIsLoadingExisting(true);
|
setIsLoadingExisting(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audiobook/status?bookId=${documentId}`);
|
const data = await getAudiobookStatus(documentId);
|
||||||
if (response.ok) {
|
if (data.exists && data.chapters.length > 0) {
|
||||||
const data = await response.json();
|
setChapters(data.chapters);
|
||||||
if (data.exists && data.chapters.length > 0) {
|
setBookId(data.bookId);
|
||||||
setChapters(data.chapters);
|
// Set format from existing chapters - this ensures the format matches what was actually generated
|
||||||
setBookId(data.bookId);
|
if (data.chapters[0]?.format) {
|
||||||
// Set format from existing chapters - this ensures the format matches what was actually generated
|
const detectedFormat = data.chapters[0].format as TTSAudiobookFormat;
|
||||||
if (data.chapters[0]?.format) {
|
setFormat(detectedFormat);
|
||||||
const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b';
|
|
||||||
setFormat(detectedFormat);
|
|
||||||
}
|
|
||||||
// If we have a complete audiobook, we're done
|
|
||||||
if (data.hasComplete) {
|
|
||||||
setProgress(100);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If nothing exists, clear chapters/bookId to reflect current state
|
|
||||||
setChapters([]);
|
|
||||||
setBookId(null);
|
|
||||||
}
|
}
|
||||||
|
// If we have a complete audiobook, we're done
|
||||||
|
if (data.hasComplete) {
|
||||||
|
setProgress(100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If nothing exists, clear chapters/bookId to reflect current state
|
||||||
|
setChapters([]);
|
||||||
|
setBookId(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching existing chapters:', error);
|
console.error('Error fetching existing chapters:', error);
|
||||||
|
|
@ -241,12 +245,7 @@ export function AudiobookExportModal({
|
||||||
const performDeleteChapter = useCallback(async () => {
|
const performDeleteChapter = useCallback(async () => {
|
||||||
if (!bookId || !pendingDeleteChapter) return;
|
if (!bookId || !pendingDeleteChapter) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
|
await deleteAudiobookChapter(bookId, pendingDeleteChapter.index);
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Delete failed');
|
|
||||||
}
|
|
||||||
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
||||||
await fetchExistingChapters(true);
|
await fetchExistingChapters(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -261,10 +260,7 @@ export function AudiobookExportModal({
|
||||||
const targetBookId = bookId || documentId;
|
const targetBookId = bookId || documentId;
|
||||||
if (!targetBookId) return;
|
if (!targetBookId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/audiobook?bookId=${targetBookId}`, { method: 'DELETE' });
|
await deleteAudiobook(targetBookId);
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('Reset failed');
|
|
||||||
}
|
|
||||||
setChapters([]);
|
setChapters([]);
|
||||||
setBookId(null);
|
setBookId(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
@ -281,10 +277,7 @@ export function AudiobookExportModal({
|
||||||
if (!chapter.bookId) return;
|
if (!chapter.bookId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audiobook/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
|
const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index);
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -306,8 +299,7 @@ export function AudiobookExportModal({
|
||||||
|
|
||||||
setIsCombining(true);
|
setIsCombining(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
|
const response = await downloadAudiobook(bookId, format);
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) throw new Error('No response body');
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
|
@ -80,8 +81,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
const handleGenerateAudiobook = useCallback(async (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
if (epub) {
|
if (epub) {
|
||||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
|
|
@ -93,7 +94,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
if (epub) {
|
if (epub) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { UploadIcon } from '@/components/icons/Icons';
|
import { UploadIcon } from '@/components/icons/Icons';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
|
import { convertDocxToPdf as convertDocxToPdfClient } from '@/lib/client';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
|
@ -23,19 +24,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const convertDocxToPdf = async (file: File): Promise<File> => {
|
const convertDocxToPdf = async (file: File): Promise<File> => {
|
||||||
const formData = new FormData();
|
const pdfBlob = await convertDocxToPdfClient(file);
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/documents/docx-to-pdf', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to convert DOCX to PDF');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBlob = await response.blob();
|
|
||||||
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
|
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { THEMES } from '@/contexts/ThemeContext';
|
import { THEMES } from '@/contexts/ThemeContext';
|
||||||
|
import { deleteServerDocuments } from '@/lib/client';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
|
@ -222,12 +223,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
const handleClearServer = async () => {
|
const handleClearServer = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents', {
|
await deleteServerDocuments();
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete server documents');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed:', error);
|
console.error('Delete failed:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,18 @@ import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { createRangeCfi } from '@/lib/epub';
|
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, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
|
import type {
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudiobookFormat,
|
||||||
|
TTSAudiobookChapter,
|
||||||
|
} from '@/types/tts';
|
||||||
import type {
|
import type {
|
||||||
TTSRequestHeaders,
|
TTSRequestHeaders,
|
||||||
TTSRequestPayload,
|
TTSRequestPayload,
|
||||||
TTSRetryOptions,
|
TTSRetryOptions,
|
||||||
TTSSentenceAlignment
|
} from '@/types/client';
|
||||||
} from '@/types/tts';
|
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -39,8 +43,8 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
|
||||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
renditionRef: RefObject<Rendition | undefined>;
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
|
@ -319,9 +323,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||||
providedBookId?: string,
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: TTSAudiobookFormat = 'mp3'
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
|
|
@ -339,18 +343,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const existingIndices = new Set<number>();
|
const existingIndices = new Set<number>();
|
||||||
if (bookId) {
|
if (bookId) {
|
||||||
try {
|
try {
|
||||||
const existingResponse = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
const existingData = await getAudiobookStatus(bookId);
|
||||||
if (existingResponse.ok) {
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
const existingData = await existingResponse.json();
|
for (const ch of existingData.chapters) {
|
||||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
existingIndices.add(ch.index);
|
||||||
for (const ch of existingData.chapters) {
|
|
||||||
existingIndices.add(ch.index);
|
|
||||||
}
|
|
||||||
// Log smallest missing index for visibility
|
|
||||||
let nextMissing = 0;
|
|
||||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
|
||||||
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
|
||||||
}
|
}
|
||||||
|
// Log smallest missing index for visibility
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking existing chapters:', error);
|
console.error('Error checking existing chapters:', error);
|
||||||
|
|
@ -431,22 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
method: 'POST',
|
|
||||||
headers: reqHeaders,
|
|
||||||
body: JSON.stringify(reqBody),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ttsResponse.ok) {
|
|
||||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await ttsResponse.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error('Received empty audio buffer from TTS');
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -469,45 +455,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch(`/api/audiobook`, {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
bookId,
|
||||||
},
|
format,
|
||||||
body: JSON.stringify({
|
chapterIndex: i
|
||||||
chapterTitle,
|
}, signal);
|
||||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
|
||||||
bookId,
|
|
||||||
format,
|
|
||||||
chapterIndex: i
|
|
||||||
}),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (convertResponse.status === 499) {
|
|
||||||
throw new Error('cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!convertResponse.ok) {
|
|
||||||
throw new Error('Failed to convert audio chapter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
|
||||||
|
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
bookId = returnedBookId;
|
bookId = chapter.bookId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify about completed chapter
|
// Notify about completed chapter
|
||||||
if (onChapterComplete) {
|
if (onChapterComplete) {
|
||||||
onChapterComplete({
|
onChapterComplete(chapter);
|
||||||
index: chapterIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
|
||||||
format
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLength += trimmedText.length;
|
processedLength += trimmedText.length;
|
||||||
|
|
@ -553,9 +515,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const regenerateChapter = useCallback(async (
|
const regenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
if (chapterIndex >= sections.length) {
|
if (chapterIndex >= sections.length) {
|
||||||
|
|
@ -620,22 +582,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
method: 'POST',
|
|
||||||
headers: reqHeaders,
|
|
||||||
body: JSON.stringify(reqBody),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ttsResponse.ok) {
|
|
||||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await ttsResponse.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error('Received empty audio buffer from TTS');
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -645,39 +592,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audiobook', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
chapterTitle,
|
|
||||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
|
||||||
bookId,
|
|
||||||
format,
|
|
||||||
chapterIndex
|
|
||||||
}),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (convertResponse.status === 499) {
|
|
||||||
throw new Error('cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!convertResponse.ok) {
|
|
||||||
throw new Error('Failed to convert audio chapter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
index: returnedIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
bookId,
|
||||||
format
|
format,
|
||||||
};
|
chapterIndex
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
return chapter;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ 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 { processTextToSentences } from '@/lib/nlp';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
|
|
@ -40,12 +40,17 @@ import {
|
||||||
highlightWordIndex,
|
highlightWordIndex,
|
||||||
} from '@/lib/pdf';
|
} from '@/lib/pdf';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudioBuffer,
|
||||||
|
TTSAudiobookFormat,
|
||||||
|
TTSAudiobookChapter,
|
||||||
|
} from '@/types/tts';
|
||||||
import type {
|
import type {
|
||||||
TTSRequestHeaders,
|
TTSRequestHeaders,
|
||||||
TTSRequestPayload,
|
TTSRequestPayload,
|
||||||
TTSRetryOptions,
|
TTSRetryOptions,
|
||||||
TTSSentenceAlignment
|
} from '@/types/client';
|
||||||
} from '@/types/tts';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
|
@ -72,8 +77,8 @@ interface PDFContextType {
|
||||||
sentence: string | null | undefined,
|
sentence: string | null | undefined,
|
||||||
containerRef: RefObject<HTMLDivElement>
|
containerRef: RefObject<HTMLDivElement>
|
||||||
) => void;
|
) => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
|
||||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,9 +262,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
|
||||||
providedBookId?: string,
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: TTSAudiobookFormat = 'mp3'
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
|
|
@ -299,17 +304,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const existingIndices = new Set<number>();
|
const existingIndices = new Set<number>();
|
||||||
if (bookId) {
|
if (bookId) {
|
||||||
try {
|
try {
|
||||||
const existingResponse = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
const existingData = await getAudiobookStatus(bookId);
|
||||||
if (existingResponse.ok) {
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
const existingData = await existingResponse.json();
|
for (const ch of existingData.chapters) {
|
||||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
existingIndices.add(ch.index);
|
||||||
for (const ch of existingData.chapters) {
|
|
||||||
existingIndices.add(ch.index);
|
|
||||||
}
|
|
||||||
let nextMissing = 0;
|
|
||||||
while (existingIndices.has(nextMissing)) nextMissing++;
|
|
||||||
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
|
||||||
}
|
}
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking existing chapters:', error);
|
console.error('Error checking existing chapters:', error);
|
||||||
|
|
@ -367,22 +369,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
method: 'POST',
|
|
||||||
headers: reqHeaders,
|
|
||||||
body: JSON.stringify(reqBody),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ttsResponse.ok) {
|
|
||||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await ttsResponse.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error('Received empty audio buffer from TTS');
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -399,45 +386,21 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch(`/api/audiobook`, {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
bookId,
|
||||||
},
|
format,
|
||||||
body: JSON.stringify({
|
chapterIndex: i
|
||||||
chapterTitle,
|
}, signal);
|
||||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
|
||||||
bookId,
|
|
||||||
format,
|
|
||||||
chapterIndex: i
|
|
||||||
}),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (convertResponse.status === 499) {
|
|
||||||
throw new Error('cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!convertResponse.ok) {
|
|
||||||
throw new Error('Failed to convert audio chapter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
|
||||||
|
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
bookId = returnedBookId;
|
bookId = chapter.bookId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify about completed chapter
|
// Notify about completed chapter
|
||||||
if (onChapterComplete) {
|
if (onChapterComplete) {
|
||||||
onChapterComplete({
|
onChapterComplete(chapter);
|
||||||
index: chapterIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
|
||||||
format
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLength += text.length;
|
processedLength += text.length;
|
||||||
|
|
@ -483,9 +446,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const regenerateChapter = useCallback(async (
|
const regenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
throw new Error('No PDF document loaded');
|
throw new Error('No PDF document loaded');
|
||||||
|
|
@ -556,28 +519,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
backoffFactor: 2
|
backoffFactor: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer: TTSAudioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
method: 'POST',
|
|
||||||
headers: reqHeaders,
|
|
||||||
body: JSON.stringify(reqBody),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ttsResponse.ok) {
|
|
||||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await ttsResponse.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error('Received empty audio buffer from TTS');
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -587,39 +535,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audiobook', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
chapterTitle,
|
|
||||||
buffer: Array.from(new Uint8Array(audioBuffer)),
|
|
||||||
bookId,
|
|
||||||
format,
|
|
||||||
chapterIndex
|
|
||||||
}),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (convertResponse.status === 499) {
|
|
||||||
throw new Error('cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!convertResponse.ok) {
|
|
||||||
throw new Error('Failed to convert audio chapter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
index: returnedIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
bookId,
|
||||||
format
|
format,
|
||||||
};
|
chapterIndex
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
return chapter;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/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, generateTTS, alignAudio } from '@/lib/client';
|
||||||
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||||
import { isKokoroModel } from '@/utils/voice';
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -44,11 +44,14 @@ import type {
|
||||||
TTSSmartMergeResult,
|
TTSSmartMergeResult,
|
||||||
TTSPageTurnEstimate,
|
TTSPageTurnEstimate,
|
||||||
TTSPlaybackState,
|
TTSPlaybackState,
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudioBuffer,
|
||||||
|
} from '@/types/tts';
|
||||||
|
import type {
|
||||||
TTSRequestPayload,
|
TTSRequestPayload,
|
||||||
TTSRequestHeaders,
|
TTSRequestHeaders,
|
||||||
TTSRetryOptions,
|
TTSRetryOptions,
|
||||||
TTSSentenceAlignment,
|
} from '@/types/client';
|
||||||
} from '@/types/tts';
|
|
||||||
|
|
||||||
// Media globals
|
// Media globals
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -731,16 +734,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* Generates and plays audio for the current sentence
|
* Generates and plays audio for the current sentence
|
||||||
*
|
*
|
||||||
* @param {string} sentence - The sentence to generate audio for
|
* @param {string} sentence - The sentence to generate audio for
|
||||||
* @returns {Promise<ArrayBuffer | undefined>} The generated audio buffer
|
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
|
||||||
*/
|
*/
|
||||||
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
|
const getAudio = useCallback(async (sentence: string): Promise<TTSAudioBuffer | undefined> => {
|
||||||
const alignmentEnabledForCurrentDoc =
|
const alignmentEnabledForCurrentDoc =
|
||||||
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||||||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
|
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
|
||||||
// Helper to ensure we have an alignment for a given
|
// Helper to ensure we have an alignment for a given
|
||||||
// sentence/audio pair, even when the audio itself is
|
// sentence/audio pair, even when the audio itself is
|
||||||
// served from the local cache.
|
// served from the local cache.
|
||||||
const ensureAlignment = (arrayBuffer: ArrayBuffer) => {
|
const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => {
|
||||||
if (!alignmentEnabledForCurrentDoc) return;
|
if (!alignmentEnabledForCurrentDoc) return;
|
||||||
if (sentenceAlignmentCacheRef.current.has(sentence)) return;
|
if (sentenceAlignmentCacheRef.current.has(sentence)) return;
|
||||||
|
|
||||||
|
|
@ -751,16 +754,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
audio: audioBytes,
|
audio: audioBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetch('/api/whisper', {
|
void alignAudio(alignmentBody)
|
||||||
method: 'POST',
|
.then(async (data) => {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(alignmentBody),
|
|
||||||
})
|
|
||||||
.then(async (res) => {
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) {
|
if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -825,19 +820,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
const arrayBuffer = await withRetry(
|
const arrayBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
return await generateTTS(reqBody, reqHeaders, controller.signal);
|
||||||
const response = await fetch('/api/tts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: reqHeaders,
|
|
||||||
body: JSON.stringify(reqBody),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to generate audio');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.arrayBuffer();
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import type { TTSAudioBuffer } from '@/types/tts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing audio cache using LRU strategy
|
* Custom hook for managing audio cache using LRU strategy
|
||||||
|
|
@ -9,11 +10,11 @@ import { LRUCache } from 'lru-cache';
|
||||||
* @returns Object containing cache methods
|
* @returns Object containing cache methods
|
||||||
*/
|
*/
|
||||||
export function useAudioCache(maxSize = 50) {
|
export function useAudioCache(maxSize = 50) {
|
||||||
const cacheRef = useRef(new LRUCache<string, ArrayBuffer>({ max: maxSize }));
|
const cacheRef = useRef(new LRUCache<string, TTSAudioBuffer>({ max: maxSize }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: (key: string) => cacheRef.current.get(key),
|
get: (key: string) => cacheRef.current.get(key),
|
||||||
set: (key: string, value: ArrayBuffer) => cacheRef.current.set(key, value),
|
set: (key: string, value: TTSAudioBuffer) => cacheRef.current.set(key, value),
|
||||||
delete: (key: string) => cacheRef.current.delete(key),
|
delete: (key: string) => cacheRef.current.delete(key),
|
||||||
has: (key: string) => cacheRef.current.has(key),
|
has: (key: string) => cacheRef.current.has(key),
|
||||||
clear: () => cacheRef.current.clear(),
|
clear: () => cacheRef.current.clear(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getVoices } from '@/lib/client';
|
||||||
|
|
||||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||||
|
|
||||||
|
|
@ -23,18 +24,14 @@ export function useVoiceManagement(
|
||||||
const fetchVoices = useCallback(async () => {
|
const fetchVoices = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching voices...');
|
console.log('Fetching voices...');
|
||||||
const response = await fetch('/api/tts/voices', {
|
const data = await getVoices({
|
||||||
headers: {
|
'x-openai-key': apiKey || '',
|
||||||
'x-openai-key': apiKey || '',
|
'x-openai-base-url': baseUrl || '',
|
||||||
'x-openai-base-url': baseUrl || '',
|
'x-tts-provider': ttsProvider || 'openai',
|
||||||
'x-tts-provider': ttsProvider || 'openai',
|
'x-tts-model': ttsModel || 'tts-1',
|
||||||
'x-tts-model': ttsModel || 'tts-1',
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch voices');
|
|
||||||
const data = await response.json();
|
|
||||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching voices:', error);
|
console.error('Error fetching voices:', error);
|
||||||
|
|
|
||||||
205
src/lib/client.ts
Normal file
205
src/lib/client.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import type {
|
||||||
|
TTSRequestPayload,
|
||||||
|
TTSRequestHeaders,
|
||||||
|
TTSRetryOptions,
|
||||||
|
AudiobookStatusResponse,
|
||||||
|
CreateChapterPayload,
|
||||||
|
VoicesResponse,
|
||||||
|
AlignmentPayload,
|
||||||
|
AlignmentResponse
|
||||||
|
} from '@/types/client';
|
||||||
|
import type { TTSAudiobookChapter, TTSAudioBuffer } from '@/types/tts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a function with exponential backoff retry logic
|
||||||
|
* @param operation Function to retry
|
||||||
|
* @param options Retry configuration options
|
||||||
|
* @returns Promise resolving to the operation result
|
||||||
|
*/
|
||||||
|
export const withRetry = async <T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: TTSRetryOptions = {}
|
||||||
|
): Promise<T> => {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
initialDelay = 1000,
|
||||||
|
maxDelay = 10000,
|
||||||
|
backoffFactor = 2
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Do not retry on explicit cancellation/abort errors - surface them
|
||||||
|
// immediately so callers can stop work quickly when the user cancels.
|
||||||
|
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
initialDelay * Math.pow(backoffFactor, attempt),
|
||||||
|
maxDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Operation failed after retries');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Documents API ---
|
||||||
|
|
||||||
|
export const convertDocxToPdf = async (file: File): Promise<Blob> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents/docx-to-pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to convert DOCX to PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteServerDocuments = async (): Promise<void> => {
|
||||||
|
const response = await fetch('/api/documents', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete server documents');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Audiobook API ---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getAudiobookStatus = async (bookId: string): Promise<AudiobookStatusResponse> => {
|
||||||
|
const response = await fetch(`/api/audiobook/status?bookId=${bookId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch audiobook status');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const createAudiobookChapter = async (
|
||||||
|
payload: CreateChapterPayload,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<TTSAudiobookChapter> => {
|
||||||
|
const response = await fetch(`/api/audiobook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 499) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAudiobook = async (bookId: string): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/audiobook?bookId=${bookId}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Reset failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAudiobook = async (bookId: string, format: string): Promise<Response> => {
|
||||||
|
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<Blob> => {
|
||||||
|
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`);
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
return await response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TTS API ---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getVoices = async (headers: HeadersInit): Promise<VoicesResponse> => {
|
||||||
|
const response = await fetch('/api/tts/voices', {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch voices');
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTTS = async (
|
||||||
|
payload: TTSRequestPayload,
|
||||||
|
headers: TTSRequestHeaders,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<TTSAudioBuffer> => {
|
||||||
|
const response = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers as HeadersInit,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Whisper API ---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const alignAudio = async (payload: AlignmentPayload): Promise<AlignmentResponse | null> => {
|
||||||
|
const response = await fetch('/api/whisper', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,6 @@ 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 '@/lib/nlp';
|
|
||||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
|
|
||||||
|
|
|
||||||
67
src/types/client.ts
Normal file
67
src/types/client.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type {
|
||||||
|
TTSAudiobookChapter,
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudioBytes,
|
||||||
|
TTSAudiobookFormat,
|
||||||
|
} from '@/types/tts';
|
||||||
|
|
||||||
|
// --- TTS Client Request Types ---
|
||||||
|
|
||||||
|
// Supported output formats for the TTS endpoint
|
||||||
|
export type TTSRequestFormat = 'mp3';
|
||||||
|
|
||||||
|
// JSON payload accepted by the /api/tts endpoint
|
||||||
|
export interface TTSRequestPayload {
|
||||||
|
text: string;
|
||||||
|
voice: string;
|
||||||
|
speed: number;
|
||||||
|
model?: string | null;
|
||||||
|
format?: TTSRequestFormat;
|
||||||
|
instructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers used when calling the /api/tts endpoint from the client
|
||||||
|
export type TTSRequestHeaders = Record<string, string>;
|
||||||
|
|
||||||
|
// Options for retrying TTS requests on failure in withRetry
|
||||||
|
export interface TTSRetryOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audiobook API Types ---
|
||||||
|
|
||||||
|
export interface AudiobookStatusResponse {
|
||||||
|
exists: boolean;
|
||||||
|
chapters: TTSAudiobookChapter[];
|
||||||
|
bookId: string | null;
|
||||||
|
hasComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChapterPayload {
|
||||||
|
chapterTitle: string;
|
||||||
|
buffer: TTSAudioBytes; // Array.from(new Uint8Array(audioBuffer))
|
||||||
|
bookId: string;
|
||||||
|
format: TTSAudiobookFormat;
|
||||||
|
chapterIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- TTS Voices API Types ---
|
||||||
|
|
||||||
|
export interface VoicesResponse {
|
||||||
|
voices: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Whisper API Types ---
|
||||||
|
|
||||||
|
export interface AlignmentPayload {
|
||||||
|
text: string;
|
||||||
|
audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlignmentResponse {
|
||||||
|
alignments: TTSSentenceAlignment[];
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
export type TTSLocation = string | number;
|
export type TTSLocation = string | number;
|
||||||
|
|
||||||
|
// Core audio representations used across TTS and audiobook flows
|
||||||
|
export type TTSAudioBuffer = ArrayBuffer;
|
||||||
|
export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer)))
|
||||||
|
|
||||||
// Standardized error codes for the TTS API
|
// Standardized error codes for the TTS API
|
||||||
export type TTSErrorCode =
|
export type TTSErrorCode =
|
||||||
| 'MISSING_PARAMETERS'
|
| 'MISSING_PARAMETERS'
|
||||||
|
|
@ -15,22 +19,6 @@ export interface TTSError {
|
||||||
details?: unknown;
|
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
|
// Core playback state exposed by the TTS context
|
||||||
export interface TTSPlaybackState {
|
export interface TTSPlaybackState {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
|
@ -42,14 +30,6 @@ export interface TTSPlaybackState {
|
||||||
currDocPages?: number;
|
currDocPages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options for retrying TTS requests on failure in withRetry
|
|
||||||
export interface TTSRetryOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
initialDelay?: number;
|
|
||||||
maxDelay?: number;
|
|
||||||
backoffFactor?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result of merging a continuation slice into the current text
|
// Result of merging a continuation slice into the current text
|
||||||
export interface TTSSmartMergeResult {
|
export interface TTSSmartMergeResult {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -79,6 +59,9 @@ export interface TTSSentenceAlignment {
|
||||||
words: TTSSentenceWord[];
|
words: TTSSentenceWord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supported output formats for generated audiobooks
|
||||||
|
export type TTSAudiobookFormat = 'mp3' | 'm4b';
|
||||||
|
|
||||||
// Metadata for an audiobook chapter
|
// Metadata for an audiobook chapter
|
||||||
export interface TTSAudiobookChapter {
|
export interface TTSAudiobookChapter {
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -86,5 +69,5 @@ export interface TTSAudiobookChapter {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
status: 'pending' | 'generating' | 'completed' | 'error';
|
status: 'pending' | 'generating' | 'completed' | 'error';
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
format?: 'mp3' | 'm4b';
|
format?: TTSAudiobookFormat;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import type { TTSRetryOptions } from '@/types/tts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a function with exponential backoff retry logic
|
|
||||||
* @param operation Function to retry
|
|
||||||
* @param options Retry configuration options
|
|
||||||
* @returns Promise resolving to the operation result
|
|
||||||
*/
|
|
||||||
export const withRetry = async <T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
options: TTSRetryOptions = {}
|
|
||||||
): Promise<T> => {
|
|
||||||
const {
|
|
||||||
maxRetries = 3,
|
|
||||||
initialDelay = 1000,
|
|
||||||
maxDelay = 10000,
|
|
||||||
backoffFactor = 2
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
|
|
||||||
// Do not retry on explicit cancellation/abort errors - surface them
|
|
||||||
// immediately so callers can stop work quickly when the user cancels.
|
|
||||||
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt === maxRetries - 1) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(
|
|
||||||
initialDelay * Math.pow(backoffFactor, attempt),
|
|
||||||
maxDelay
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError || new Error('Operation failed after retries');
|
|
||||||
};
|
|
||||||
Loading…
Reference in a new issue