Skip to content

Commit b576910

Browse files
committed
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`.
1 parent 7a29f73 commit b576910

File tree

20 files changed

+455
-439
lines changed

20 files changed

+455
-439
lines changed

src/app/api/audiobook/route.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises';
44
import { existsSync, createReadStream } from 'fs';
55
import { join } from 'path';
66
import { randomUUID } from 'crypto';
7+
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
78

89
interface ConversionRequest {
910
chapterTitle: string;
10-
buffer: number[];
11+
buffer: TTSAudioBytes;
1112
bookId?: string;
12-
format?: 'mp3' | 'm4b';
13+
format?: TTSAudiobookFormat;
1314
chapterIndex?: number;
1415
}
1516

@@ -206,9 +207,12 @@ export async function POST(request: NextRequest) {
206207
await unlink(inputPath).catch(console.error);
207208

208209
return NextResponse.json({
210+
index: chapterIndex,
211+
title: data.chapterTitle,
212+
duration,
213+
status: 'completed' as const,
209214
bookId,
210-
chapterIndex,
211-
duration
215+
format
212216
});
213217

214218
} catch (error) {
@@ -229,7 +233,7 @@ export async function POST(request: NextRequest) {
229233
export async function GET(request: NextRequest) {
230234
try {
231235
const bookId = request.nextUrl.searchParams.get('bookId');
232-
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
236+
const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null;
233237
if (!bookId) {
234238
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
235239
}
@@ -405,4 +409,4 @@ export async function DELETE(request: NextRequest) {
405409
{ status: 500 }
406410
);
407411
}
408-
}
412+
}

src/app/api/audiobook/status/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { readdir, readFile } from 'fs/promises';
33
import { existsSync } from 'fs';
44
import { join } from 'path';
5+
import type { TTSAudiobookFormat } from '@/types/tts';
56

67
export async function GET(request: NextRequest) {
78
try {
@@ -26,7 +27,7 @@ export async function GET(request: NextRequest) {
2627
duration?: number;
2728
status: 'completed' | 'error';
2829
bookId: string;
29-
format?: 'mp3' | 'm4b';
30+
format?: TTSAudiobookFormat;
3031
}> = [];
3132

3233
for (const metaFile of metaFiles) {
@@ -68,5 +69,3 @@ export async function GET(request: NextRequest) {
6869
);
6970
}
7071
}
71-
72-

src/app/api/tts/route.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
44
import { isKokoroModel } from '@/utils/voice';
55
import { LRUCache } from 'lru-cache';
66
import { createHash } from 'crypto';
7-
import type { TTSRequestPayload, TTSError } from '@/types/tts';
7+
import type { TTSRequestPayload } from '@/types/client';
8+
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
89

910
export const runtime = 'nodejs';
1011

@@ -13,7 +14,7 @@ type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
1314
voice: SpeechCreateParams['voice'] | CustomVoice;
1415
instructions?: string;
1516
};
16-
type AudioBufferValue = ArrayBuffer;
17+
type AudioBufferValue = TTSAudioBuffer;
1718

1819
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
1920
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>({
2526
});
2627

2728
type InflightEntry = {
28-
promise: Promise<ArrayBuffer>;
29+
promise: Promise<TTSAudioBuffer>;
2930
controller: AbortController;
3031
consumers: number;
3132
};
@@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry(
4041
openai: OpenAI,
4142
createParams: ExtendedSpeechParams,
4243
signal: AbortSignal
43-
): Promise<ArrayBuffer> {
44+
): Promise<TTSAudioBuffer> {
4445
let attempt = 0;
4546
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
4647
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
@@ -135,15 +136,15 @@ export async function POST(req: NextRequest) {
135136
voice: normalizedVoice,
136137
input: text,
137138
speed: speed,
138-
response_format: format === 'aac' ? 'aac' : 'mp3',
139+
response_format: format,
139140
};
140141
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
141142
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
142143
createParams.instructions = instructions;
143144
}
144145

145146
// Compute cache key and check LRU before making provider call
146-
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
147+
const contentType = 'audio/mpeg';
147148

148149
// Preserve voice string as-is for cache key (no weight stripping)
149150
const voiceForKey = typeof createParams.voice === 'string'
@@ -245,7 +246,7 @@ export async function POST(req: NextRequest) {
245246
};
246247
req.signal.addEventListener('abort', onAbort, { once: true });
247248

248-
let buffer: ArrayBuffer;
249+
let buffer: TTSAudioBuffer;
249250
try {
250251
buffer = await entry.promise;
251252
} finally {
@@ -280,4 +281,4 @@ export async function POST(req: NextRequest) {
280281
{ status: 500 }
281282
);
282283
}
283-
}
284+
}

src/app/api/whisper/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises';
44
import { tmpdir } from 'os';
55
import { join } from 'path';
66
import { spawn } from 'child_process';
7-
import type { TTSSentenceAlignment } from '@/types/tts';
7+
import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts';
88
import { preprocessSentenceForAudio } from '@/lib/nlp';
99

1010
export const runtime = 'nodejs';
1111

1212
interface WhisperRequestBody {
1313
text: string;
14-
audio: number[]; // raw bytes from Uint8Array
14+
audio: TTSAudioBytes; // raw bytes from Uint8Array
1515
lang?: string;
1616
}
1717

@@ -331,7 +331,7 @@ function mapWordsToSentenceOffsets(
331331
}
332332

333333
async function alignAudioWithText(
334-
audioBuffer: ArrayBuffer,
334+
audioBuffer: TTSAudioBuffer,
335335
text: string,
336336
cacheKey?: string,
337337
opts: WhisperAlignmentOptions = {}
@@ -448,4 +448,3 @@ export async function POST(req: NextRequest) {
448448
);
449449
}
450450
}
451-

src/app/epub/[id]/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import TTSPlayer from '@/components/player/TTSPlayer';
1414
import { ZoomControl } from '@/components/ZoomControl';
1515
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
1616
import { DownloadIcon } from '@/components/icons/Icons';
17+
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
1718

1819
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
1920

@@ -86,16 +87,16 @@ export default function EPUBPage() {
8687
const handleGenerateAudiobook = useCallback(async (
8788
onProgress: (progress: number) => void,
8889
signal: AbortSignal,
89-
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
90-
format: 'mp3' | 'm4b'
90+
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
91+
format: TTSAudiobookFormat
9192
) => {
9293
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
9394
}, [createEPUBAudioBook, id]);
9495

9596
const handleRegenerateChapter = useCallback(async (
9697
chapterIndex: number,
9798
bookId: string,
98-
format: 'mp3' | 'm4b',
99+
format: TTSAudiobookFormat,
99100
signal: AbortSignal
100101
) => {
101102
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
@@ -190,4 +191,4 @@ export default function EPUBPage() {
190191
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
191192
</>
192193
);
193-
}
194+
}

src/app/pdf/[id]/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
1212
import { Header } from '@/components/Header';
1313
import { ZoomControl } from '@/components/ZoomControl';
1414
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
15+
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
1516
import TTSPlayer from '@/components/player/TTSPlayer';
1617

1718
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@@ -80,16 +81,16 @@ export default function PDFViewerPage() {
8081
const handleGenerateAudiobook = useCallback(async (
8182
onProgress: (progress: number) => void,
8283
signal: AbortSignal,
83-
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
84-
format: 'mp3' | 'm4b'
84+
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
85+
format: TTSAudiobookFormat
8586
) => {
8687
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
8788
}, [createPDFAudioBook, id]);
8889

8990
const handleRegenerateChapter = useCallback(async (
9091
chapterIndex: number,
9192
bookId: string,
92-
format: 'mp3' | 'm4b',
93+
format: TTSAudiobookFormat,
9394
signal: AbortSignal
9495
) => {
9596
return regeneratePDFChapter(chapterIndex, bookId, format, signal);

src/components/AudiobookExportModal.tsx

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
99
import { ConfirmDialog } from '@/components/ConfirmDialog';
1010
import { LoadingSpinner } from '@/components/Spinner';
1111
import { useConfig } from '@/contexts/ConfigContext';
12-
import type { TTSAudiobookChapter } from '@/types/tts';
12+
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
13+
import {
14+
getAudiobookStatus,
15+
deleteAudiobookChapter,
16+
deleteAudiobook,
17+
downloadAudiobookChapter,
18+
downloadAudiobook
19+
} from '@/lib/client';
1320
interface AudiobookExportModalProps {
1421
isOpen: boolean;
1522
setIsOpen: (isOpen: boolean) => void;
@@ -19,12 +26,12 @@ interface AudiobookExportModalProps {
1926
onProgress: (progress: number) => void,
2027
signal: AbortSignal,
2128
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
22-
format: 'mp3' | 'm4b'
29+
format: TTSAudiobookFormat
2330
) => Promise<string>; // Returns bookId
2431
onRegenerateChapter?: (
2532
chapterIndex: number,
2633
bookId: string,
27-
format: 'mp3' | 'm4b',
34+
format: TTSAudiobookFormat,
2835
signal: AbortSignal
2936
) => Promise<TTSAudiobookChapter>;
3037
}
@@ -46,7 +53,7 @@ export function AudiobookExportModal({
4653
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
4754
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
4855
const [currentChapter, setCurrentChapter] = useState<string>('');
49-
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
56+
const [format, setFormat] = useState<TTSAudiobookFormat>('m4b');
5057
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
5158
const abortControllerRef = useRef<AbortController | null>(null);
5259
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
@@ -61,26 +68,23 @@ export function AudiobookExportModal({
6168
setIsLoadingExisting(true);
6269
}
6370
try {
64-
const response = await fetch(`/api/audiobook/status?bookId=${documentId}`);
65-
if (response.ok) {
66-
const data = await response.json();
67-
if (data.exists && data.chapters.length > 0) {
68-
setChapters(data.chapters);
69-
setBookId(data.bookId);
70-
// Set format from existing chapters - this ensures the format matches what was actually generated
71-
if (data.chapters[0]?.format) {
72-
const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b';
73-
setFormat(detectedFormat);
74-
}
75-
// If we have a complete audiobook, we're done
76-
if (data.hasComplete) {
77-
setProgress(100);
78-
}
79-
} else {
80-
// If nothing exists, clear chapters/bookId to reflect current state
81-
setChapters([]);
82-
setBookId(null);
71+
const data = await getAudiobookStatus(documentId);
72+
if (data.exists && data.chapters.length > 0) {
73+
setChapters(data.chapters);
74+
setBookId(data.bookId);
75+
// Set format from existing chapters - this ensures the format matches what was actually generated
76+
if (data.chapters[0]?.format) {
77+
const detectedFormat = data.chapters[0].format as TTSAudiobookFormat;
78+
setFormat(detectedFormat);
8379
}
80+
// If we have a complete audiobook, we're done
81+
if (data.hasComplete) {
82+
setProgress(100);
83+
}
84+
} else {
85+
// If nothing exists, clear chapters/bookId to reflect current state
86+
setChapters([]);
87+
setBookId(null);
8488
}
8589
} catch (error) {
8690
console.error('Error fetching existing chapters:', error);
@@ -241,12 +245,7 @@ export function AudiobookExportModal({
241245
const performDeleteChapter = useCallback(async () => {
242246
if (!bookId || !pendingDeleteChapter) return;
243247
try {
244-
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
245-
method: 'DELETE'
246-
});
247-
if (!response.ok) {
248-
throw new Error('Delete failed');
249-
}
248+
await deleteAudiobookChapter(bookId, pendingDeleteChapter.index);
250249
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
251250
await fetchExistingChapters(true);
252251
} catch (error) {
@@ -261,10 +260,7 @@ export function AudiobookExportModal({
261260
const targetBookId = bookId || documentId;
262261
if (!targetBookId) return;
263262
try {
264-
const resp = await fetch(`/api/audiobook?bookId=${targetBookId}`, { method: 'DELETE' });
265-
if (!resp.ok) {
266-
throw new Error('Reset failed');
267-
}
263+
await deleteAudiobook(targetBookId);
268264
setChapters([]);
269265
setBookId(null);
270266
setProgress(0);
@@ -281,10 +277,7 @@ export function AudiobookExportModal({
281277
if (!chapter.bookId) return;
282278

283279
try {
284-
const response = await fetch(`/api/audiobook/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
285-
if (!response.ok) throw new Error('Download failed');
286-
287-
const blob = await response.blob();
280+
const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index);
288281
const url = URL.createObjectURL(blob);
289282
const a = document.createElement('a');
290283
a.href = url;
@@ -306,8 +299,7 @@ export function AudiobookExportModal({
306299

307300
setIsCombining(true);
308301
try {
309-
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
310-
if (!response.ok) throw new Error('Download failed');
302+
const response = await downloadAudiobook(bookId, format);
311303

312304
const reader = response.body?.getReader();
313305
if (!reader) throw new Error('No response body');

src/components/DocumentSettings.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useEPUB } from '@/contexts/EPUBContext';
88
import { usePDF } from '@/contexts/PDFContext';
99
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
1010
import { useParams } from 'next/navigation';
11+
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
1112

1213
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
1314

@@ -80,8 +81,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
8081
const handleGenerateAudiobook = useCallback(async (
8182
onProgress: (progress: number) => void,
8283
signal: AbortSignal,
83-
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
84-
format: 'mp3' | 'm4b'
84+
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
85+
format: TTSAudiobookFormat
8586
) => {
8687
if (epub) {
8788
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
@@ -93,7 +94,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
9394
const handleRegenerateChapter = useCallback(async (
9495
chapterIndex: number,
9596
bookId: string,
96-
format: 'mp3' | 'm4b',
97+
format: TTSAudiobookFormat,
9798
signal: AbortSignal
9899
) => {
99100
if (epub) {

0 commit comments

Comments
 (0)