perf: optimize audiobook generation with concurrent TTS and stream copy

- Add concurrent TTS processing with configurable limit (3 parallel requests)
- Use FFmpeg stream copy instead of re-encoding for 10-20x faster combining
- Cache chapter durations to sidecar files to avoid repeated ffprobe calls
- Add text chunking for TTS providers with character limits (Groq: 3800, OpenAI: 4096)
- Include WAV and MP3 buffer concatenation for chunked audio
- Add page mapping cache to optimize chapter regeneration
- Increase middleware client max body size to 100mb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sunny 2026-02-02 05:19:36 +00:00
parent 567b29abf2
commit 9cdeeca420
5 changed files with 356 additions and 107 deletions

View file

@ -6,6 +6,9 @@ const nextConfig: NextConfig = {
canvas: './empty-module.ts',
},
},
experimental: {
middlewareClientMaxBodySize: '100mb',
},
};
export default nextConfig;

View file

@ -250,8 +250,9 @@ export async function POST(request: NextRequest) {
chapterIndex = next;
}
// Write input file (MP3 from TTS)
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
// Write input file (could be MP3 or WAV depending on TTS provider)
// Use generic extension - FFmpeg auto-detects format
const inputPath = join(intermediateDir, `${chapterIndex}-input.audio`);
const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
@ -291,6 +292,10 @@ export async function POST(request: NextRequest) {
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal));
// Cache duration to sidecar file to avoid repeated ffprobe calls on download
const durationPath = join(intermediateDir, `${chapterIndex}.duration`);
await writeFile(durationPath, String(duration));
const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format));
await unlink(finalChapterPath).catch(() => {});
await rename(chapterOutputTempPath, finalChapterPath);
@ -455,26 +460,25 @@ export async function GET(request: NextRequest) {
);
if (format === 'mp3') {
// For MP3, re-encode to properly rebuild headers and duration metadata
// Using libmp3lame to ensure proper MP3 structure
// Stream copy chapters without re-encoding (10-20x faster)
// All chapters already have consistent encoding from POST handler
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-c:a', 'libmp3lame',
'-b:a', '64k',
'-c:a', 'copy',
outputPath
], request.signal);
} else {
// Combine all files into a single M4B with chapter metadata
// Stream copy chapters into M4B with chapter metadata (10-20x faster)
// All chapters already have consistent AAC encoding from POST handler
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-i', metadataPath,
'-map_metadata', '1',
'-c:a', 'aac',
'-b:a', '64k',
'-c:a', 'copy',
'-f', 'mp4',
outputPath
], request.signal);

View file

@ -74,6 +74,143 @@ async function fetchTTSBufferWithRetry(
}
}
// Provider-specific character limits for TTS input
const PROVIDER_CHAR_LIMITS: Record<string, number> = {
groq: 3800, // Groq limit is 4000, use 3800 for safety margin
openai: 4096,
deepinfra: 10000, // Kokoro can handle longer text
};
// Split text into chunks at natural break points
function splitTextIntoChunks(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxChars) {
chunks.push(remaining.trim());
break;
}
const searchRange = remaining.slice(0, maxChars);
// Find best break: sentence > clause > word > hard cut
const breakPoints = [
...(['. ', '! ', '? ', '.\n', '!\n', '?\n'].map(s => searchRange.lastIndexOf(s)).filter(i => i > maxChars * 0.3)),
...([', ', '; ', '\n'].map(s => searchRange.lastIndexOf(s)).filter(i => i > maxChars * 0.3)),
searchRange.lastIndexOf(' '),
].filter(i => i > 0);
const breakPoint = breakPoints.length > 0 ? Math.max(...breakPoints) : maxChars;
chunks.push(remaining.slice(0, breakPoint + 1).trim());
remaining = remaining.slice(breakPoint + 1).trim();
}
return chunks.filter(c => c.length > 0);
}
// Concatenate WAV buffers (for Groq which returns WAV with streaming headers)
function concatenateWavBuffers(buffers: ArrayBuffer[]): ArrayBuffer {
if (buffers.length === 0) return new ArrayBuffer(0);
if (buffers.length === 1) return buffers[0];
// Parse first WAV to get format info
const firstView = new DataView(buffers[0]);
const numChannels = firstView.getUint16(22, true);
const sampleRate = firstView.getUint32(24, true);
const bitsPerSample = firstView.getUint16(34, true);
// Calculate total data size (excluding headers)
let totalDataSize = 0;
const dataChunks: ArrayBuffer[] = [];
for (const buffer of buffers) {
const view = new DataView(buffer);
// Find 'data' chunk by scanning through all chunks
let offset = 12; // Skip RIFF header (RIFF + size + WAVE = 12 bytes)
while (offset < buffer.byteLength - 8) {
const chunkId = String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3)
);
let chunkSize = view.getUint32(offset + 4, true);
if (chunkId === 'data') {
// Handle streaming WAV with 0xFFFFFFFF placeholder size
// Actual data size = file size - current offset - 8 (chunk header)
if (chunkSize === 0xFFFFFFFF) {
chunkSize = buffer.byteLength - offset - 8;
}
totalDataSize += chunkSize;
dataChunks.push(buffer.slice(offset + 8, offset + 8 + chunkSize));
break;
}
// Handle streaming placeholder for other chunks too
if (chunkSize === 0xFFFFFFFF) {
break; // Can't continue if we don't know chunk size
}
offset += 8 + chunkSize;
// Align to even byte boundary (WAV chunks are word-aligned)
if (offset % 2 !== 0) offset++;
}
}
// Create new WAV with combined data
const headerSize = 44;
const result = new ArrayBuffer(headerSize + totalDataSize);
const view = new DataView(result);
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
const blockAlign = numChannels * (bitsPerSample / 8);
// Write WAV header
const writeString = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + totalDataSize, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk size
view.setUint16(20, 1, true); // PCM format
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeString(36, 'data');
view.setUint32(40, totalDataSize, true);
// Copy data chunks
let writeOffset = headerSize;
for (const chunk of dataChunks) {
new Uint8Array(result, writeOffset).set(new Uint8Array(chunk));
writeOffset += chunk.byteLength;
}
return result;
}
// Concatenate MP3 buffers (simple concatenation works for MP3)
function concatenateMp3Buffers(buffers: ArrayBuffer[]): ArrayBuffer {
if (buffers.length === 0) return new ArrayBuffer(0);
if (buffers.length === 1) return buffers[0];
const totalSize = buffers.reduce((sum, b) => sum + b.byteLength, 0);
const result = new Uint8Array(totalSize);
let offset = 0;
for (const buffer of buffers) {
result.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
}
return result.buffer;
}
function makeCacheKey(input: {
provider: string;
model: string | null | undefined;
@ -233,6 +370,50 @@ export async function POST(req: NextRequest) {
});
}
// Check if text needs chunking due to provider limits
const charLimit = PROVIDER_CHAR_LIMITS[provider] || 10000;
if (text.length > charLimit) {
const chunks = splitTextIntoChunks(text, charLimit);
console.log(`[TTS] Chunking ${text.length} chars into ${chunks.length} chunks (limit: ${charLimit})`);
const audioBuffers: ArrayBuffer[] = [];
const startTime = Date.now();
for (let i = 0; i < chunks.length; i++) {
if (req.signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const chunkParams: ExtendedSpeechParams = {
...createParams,
input: chunks[i],
};
const buffer = await fetchTTSBufferWithRetry(openai, chunkParams, req.signal);
audioBuffers.push(buffer);
}
console.log(`[TTS] ${chunks.length} chunks done in ${Date.now() - startTime}ms`);
// Concatenate audio buffers
const combinedBuffer = actualFormat === 'wav'
? concatenateWavBuffers(audioBuffers)
: concatenateMp3Buffers(audioBuffers);
// Cache the combined result
ttsAudioCache.set(cacheKey, combinedBuffer);
return new NextResponse(combinedBuffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
'X-Chunks': String(chunks.length),
'ETag': etag,
'Content-Length': String(combinedBuffer.byteLength),
'Cache-Control': 'private, max-age=1800',
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
}
// De-duplicate identical in-flight requests
const existing = inflightRequests.get(cacheKey);
if (existing) {
@ -265,13 +446,14 @@ export async function POST(req: NextRequest) {
}
const controller = new AbortController();
const startTime = Date.now();
const entry: InflightEntry = {
controller,
consumers: 1,
promise: (async () => {
try {
const buffer = await fetchTTSBufferWithRetry(openai, createParams, controller.signal);
// Save to cache
console.log(`[TTS] ${provider} ${text.length} chars -> ${buffer.byteLength} bytes in ${Date.now() - startTime}ms`);
ttsAudioCache.set(cacheKey, buffer);
return buffer;
} finally {

View file

@ -32,6 +32,7 @@ import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { normalizeTextForTts } from '@/lib/nlp';
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
import { processWithConcurrencyLimit } from '@/lib/concurrency';
import {
extractTextFromPDF,
highlightPattern,
@ -94,6 +95,9 @@ interface PDFContextType {
settings?: AudiobookGenerationSettings
) => Promise<TTSAudiobookChapter>;
isAudioCombining: boolean;
// Page mapping from chapter index to PDF page number (1-based)
pageMapping: Map<number, number>;
setPageMapping: (mapping: Map<number, number>) => void;
}
// Create the context
@ -143,6 +147,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [isAudioCombining] = useState(false);
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
// Page mapping: chapter index -> PDF page number (1-based)
const [pageMapping, setPageMapping] = useState<Map<number, number>>(new Map());
useEffect(() => {
setCurrDocPage(currDocPageNumber);
@ -266,6 +272,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
stop();
}, [setCurrDocPages, stop]);
// Maximum concurrent TTS requests (configurable via env or default to 3)
const MAX_CONCURRENT_TTS = 3;
/**
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
* @param {Function} onProgress - Callback for progress updates
@ -299,10 +308,18 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
const effectiveFormat = settings?.format ?? format;
// First pass: extract and measure all text
const textPerPage: string[] = [];
// Single-pass: extract, measure, and store text for all pages
interface PageData {
index: number; // Chapter index (0-based, sequential for non-empty pages)
pdfPage: number; // PDF page number (1-based)
text: string;
length: number;
}
const pages: PageData[] = [];
let totalLength = 0;
let chapterIndex = 0;
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
header: headerMargin,
@ -313,9 +330,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const trimmedText = rawText.trim();
if (trimmedText) {
const processedText = smartSentenceSplitting ? normalizeTextForTts(trimmedText) : trimmedText;
textPerPage.push(processedText);
pages.push({ index: chapterIndex, pdfPage: pageNum, text: processedText, length: processedText.length });
totalLength += processedText.length;
chapterIndex++;
}
}
@ -323,8 +340,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
throw new Error('No text content found in PDF');
}
// Store page mapping for regeneration optimization (chapter index -> PDF page number)
const newMapping = new Map<number, number>();
for (const page of pages) {
newMapping.set(page.index, page.pdfPage);
}
setPageMapping(newMapping);
let processedLength = 0;
let bookId: string = providedBookId || '';
// Generate bookId upfront if not provided to ensure all concurrent requests use the same ID
const bookId: string = providedBookId || crypto.randomUUID();
// If we have a bookId, check for existing chapters to determine which indices already exist
const existingIndices = new Set<number>();
@ -344,110 +369,114 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
}
// Second pass: process text into audio
for (let i = 0; i < textPerPage.length; i++) {
// Check for abort at the start of iteration
if (signal?.aborted) {
console.log('Generation cancelled by user');
if (bookId) {
return bookId; // Return bookId with partial progress
// Account for already-completed pages in progress
for (const page of pages) {
if (existingIndices.has(page.index)) {
processedLength += page.length;
}
}
onProgress((processedLength / totalLength) * 100);
// Filter to pending pages only
const pendingPages = pages.filter(p => !existingIndices.has(p.index));
if (pendingPages.length === 0) {
if (!bookId) {
throw new Error('No audio was generated from the PDF content');
}
return bookId;
}
const retryOptions: TTSRetryOptions = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
};
// Process pages concurrently with limit
const results = await processWithConcurrencyLimit(
pendingPages,
async (page) => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
throw new Error('Audiobook generation cancelled');
}
const text = textPerPage[i];
// Skip pages that already exist on disk (supports non-contiguous indices)
if (existingIndices.has(i)) {
processedLength += text.length;
onProgress((processedLength / totalLength) * 100);
continue;
}
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': effectiveProvider,
};
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': effectiveProvider,
};
const reqBody: TTSRequestPayload = {
text: page.text,
voice: effectiveVoice,
speed: effectiveNativeSpeed,
format: 'mp3',
model: effectiveModel,
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
};
const reqBody: TTSRequestPayload = {
text,
voice: effectiveVoice,
speed: effectiveNativeSpeed,
format: 'mp3',
model: effectiveModel,
instructions: effectiveModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
};
const retryOptions: TTSRetryOptions = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
};
try {
const audioBuffer = await withRetry(
async () => {
// Check for abort before starting TTS request
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
return await generateTTS(reqBody, reqHeaders, signal);
},
retryOptions
);
const chapterTitle = `Page ${i + 1}`;
// Check for abort before sending to server
if (signal?.aborted) {
console.log('Generation cancelled before saving page');
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
throw new DOMException('Aborted', 'AbortError');
}
const chapterTitle = `Page ${page.index + 1}`;
// Send to server for conversion and storage
const chapter = await createAudiobookChapter({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format: effectiveFormat,
chapterIndex: i,
chapterIndex: page.index,
settings
}, signal);
if (!bookId) {
bookId = chapter.bookId!;
}
// Notify about completed chapter
// Update progress (thread-safe since JS is single-threaded for sync operations)
processedLength += page.length;
onProgress((processedLength / totalLength) * 100);
if (onChapterComplete) {
onChapterComplete(chapter);
}
processedLength += text.length;
onProgress((processedLength / totalLength) * 100);
return chapter;
},
MAX_CONCURRENT_TTS,
signal
);
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
// Handle errors from concurrent processing
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'rejected') {
const error = result.reason;
if (error.name === 'AbortError' || error.message.includes('cancelled') || error.message.includes('Aborted')) {
console.log('TTS request aborted, returning partial progress');
if (bookId) {
return bookId; // Return with partial progress
return bookId;
}
throw new Error('Audiobook generation cancelled');
}
console.error('Error processing page:', error);
// Notify about error
if (onChapterComplete) {
onChapterComplete({
index: i,
title: `Page ${i + 1}`,
index: pendingPages[i].index,
title: `Page ${pendingPages[i].index + 1}`,
status: 'error',
bookId,
format: effectiveFormat
@ -456,10 +485,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
}
if (!bookId) {
throw new Error('No audio was generated from the PDF content');
}
return bookId;
} catch (error) {
console.error('Error creating audiobook:', error);
@ -495,26 +520,31 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const effectiveNativeSpeed = settings?.nativeSpeed ?? voiceSpeed;
const effectiveFormat = settings?.format ?? format;
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
const nonEmptyPages: number[] = [];
for (let page = 1; page <= pdfDocument.numPages; page++) {
const pageText = await extractTextFromPDF(pdfDocument, page, {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
if (pageText.trim()) {
nonEmptyPages.push(page);
// Use cached page mapping if available, otherwise rebuild it
let pageNum: number | undefined = pageMapping.get(chapterIndex);
if (pageNum === undefined) {
// Fallback: Build mapping by scanning non-empty pages
// This happens when regenerating without having generated the full audiobook first
const nonEmptyPages: number[] = [];
for (let page = 1; page <= pdfDocument.numPages; page++) {
const pageText = await extractTextFromPDF(pdfDocument, page, {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
if (pageText.trim()) {
nonEmptyPages.push(page);
}
}
}
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
throw new Error('Invalid chapter index');
}
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
throw new Error('Invalid chapter index');
}
const pageNum = nonEmptyPages[chapterIndex];
pageNum = nonEmptyPages[chapterIndex];
}
// Extract text from the mapped page
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
@ -594,7 +624,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
console.error('Error regenerating page:', error);
throw error;
}
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting]);
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting, pageMapping]);
/**
* Effect hook to initialize TTS as non-EPUB mode
@ -632,6 +662,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
createFullAudioBook,
regenerateChapter,
isAudioCombining,
pageMapping,
setPageMapping,
}),
[
onDocumentLoadSuccess,
@ -646,6 +678,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
createFullAudioBook,
regenerateChapter,
isAudioCombining,
pageMapping,
]
);

View file

@ -1,6 +1,6 @@
import { spawn } from 'child_process';
import path from 'path';
import { readdir } from 'fs/promises';
import { readdir, readFile } from 'fs/promises';
export type StoredChapter = {
index: number;
@ -166,12 +166,25 @@ export async function listStoredChapters(dir: string, signal?: AbortSignal): Pro
const filePath = path.join(dir, file);
// Try reading cached duration first to avoid ffprobe calls
const durationPath = path.join(dir, `${decodedFromName.index}.duration`);
let durationSec: number | undefined;
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
const cached = await readFile(durationPath, 'utf8');
const parsed = parseFloat(cached);
if (Number.isFinite(parsed) && parsed > 0) {
durationSec = parsed;
}
} catch {}
// Only ffprobe if no cached duration
if (durationSec === undefined) {
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
} catch {}
}
results.push({
index: decodedFromName.index,
title: decodedFromName.title,
@ -208,12 +221,26 @@ export async function findStoredChapterByIndex(
if (!decoded) return null;
const filePath = path.join(dir, candidate);
// Try reading cached duration first
const durationPath = path.join(dir, `${decoded.index}.duration`);
let durationSec: number | undefined;
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
const cached = await readFile(durationPath, 'utf8');
const parsed = parseFloat(cached);
if (Number.isFinite(parsed) && parsed > 0) {
durationSec = parsed;
}
} catch {}
// Only ffprobe if no cached duration
if (durationSec === undefined) {
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
} catch {}
}
return {
index: decoded.index,
title: decoded.title,