Further audiobook extraction optimizations

This commit is contained in:
Richard Roberson 2025-02-26 04:03:05 -07:00
parent 568c9f2095
commit e3370f7526
5 changed files with 73 additions and 58 deletions

View file

@ -42,7 +42,7 @@ https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b
docker run --name openreader-webui \ docker run --name openreader-webui \
-p 3003:3003 \ -p 3003:3003 \
-v openreader_docstore:/app/docstore \ -v openreader_docstore:/app/docstore \
richardr1126/openreader-webui:v0.2.2-alpine richardr1126/openreader-webui:latest
``` ```
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices (Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
@ -51,7 +51,7 @@ docker run --name openreader-webui \
-e API_BASE=http://host.docker.internal:8880/v1 \ -e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \ -p 3003:3003 \
-v openreader_docstore:/app/docstore \ -v openreader_docstore:/app/docstore \
richardr1126/openreader-webui:v0.2.2-alpine richardr1126/openreader-webui:latest
``` ```
> Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. > Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`.

View file

@ -108,7 +108,7 @@ export async function POST(request: NextRequest) {
for (let i = 0; i < data.chapters.length; i++) { for (let i = 0; i < data.chapters.length; i++) {
const chapter = data.chapters[i]; const chapter = data.chapters[i];
const inputPath = join(intermediateDir, `${i}-input.mp3`); const inputPath = join(intermediateDir, `${i}-input.mp3`);
const outputPath = join(intermediateDir, `${i}.wav`); const outputPath = join(intermediateDir, `${i}.aac`);
tempFiles.push(inputPath, outputPath); tempFiles.push(inputPath, outputPath);
@ -124,12 +124,10 @@ export async function POST(request: NextRequest) {
await writeFile(inputPath, Buffer.concat(chunks)); await writeFile(inputPath, Buffer.concat(chunks));
chunks.length = 0; // Clear chunks array chunks.length = 0; // Clear chunks array
// Convert to WAV with consistent format // Copy to AAC format for compatibility with M4B
await runFFmpeg([ await runFFmpeg([
'-i', inputPath, '-i', inputPath,
'-acodec', 'pcm_s16le', '-c:a', 'copy', // Use copy instead of re-encoding
'-ar', '44100',
'-ac', '2',
outputPath outputPath
]); ]);
@ -177,16 +175,18 @@ export async function POST(request: NextRequest) {
chapterFiles.map(f => `file '${f.path}'`).join('\n') chapterFiles.map(f => `file '${f.path}'`).join('\n')
); );
// Combine all files into a single M4B // Combine all files into a single M4B with optimized settings
await runFFmpeg([ await runFFmpeg([
'-f', 'concat', '-f', 'concat',
'-safe', '0', '-safe', '0',
'-i', listPath, '-i', listPath,
'-i', metadataPath, '-i', metadataPath,
'-map_metadata', '1', '-map_metadata', '1',
'-c:a', 'aac', '-c:a', 'copy', // Use macOS AudioToolbox AAC encoder
'-b:a', '192k', //'-b:a', '192k',
'-threads', '0', // Use maximum available threads
'-movflags', '+faststart', '-movflags', '+faststart',
'-preset', 'ultrafast', // Use fastest encoding preset
outputPath outputPath
]); ]);

View file

@ -6,8 +6,8 @@ export async function POST(req: NextRequest) {
// Get API credentials from headers or fall back to environment variables // Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const { text, voice, speed } = await req.json(); const { text, voice, speed, format } = await req.json();
console.log('Received TTS request:', text, voice, speed); console.log('Received TTS request:', text, voice, speed, format);
if (!openApiKey) { if (!openApiKey) {
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 }); return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
@ -29,16 +29,18 @@ export async function POST(req: NextRequest) {
voice: voice as "alloy", voice: voice as "alloy",
input: text, input: text,
speed: speed, speed: speed,
response_format: 'mp3', // Always use mp3 since we convert to WAV later if needed response_format: format === 'aac' ? 'aac' : 'mp3',
}, { signal: req.signal }); }, { signal: req.signal }); // Pass the abort signal to OpenAI client
// Get the audio data as array buffer // Get the audio data as array buffer
// This will also be aborted if the client cancels
const stream = response.body; const stream = response.body;
// Return audio data with appropriate headers // Return audio data with appropriate headers
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
return new NextResponse(stream, { return new NextResponse(stream, {
headers: { headers: {
'Content-Type': 'audio/mpeg' 'Content-Type': contentType
} }
}); });
} catch (error) { } catch (error) {

View file

@ -144,39 +144,40 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
* Extracts text content from the entire EPUB book * Extracts text content from the entire EPUB book
* @returns {Promise<string[]>} Array of text content from each section * @returns {Promise<string[]>} Array of text content from each section
*/ */
const extractBookText = useCallback(async (): Promise<string[]> => { const extractBookText = useCallback(async (): Promise<Array<{ text: string; href: string }>> => {
try { try {
if (!bookRef.current || !bookRef.current.isOpen) return ['']; if (!bookRef.current || !bookRef.current.isOpen) return [{ text: '', href: '' }];
const book = bookRef.current; const book = bookRef.current;
const spine = book.spine; const spine = book.spine;
const promises: Promise<string>[] = []; const promises: Promise<{ text: string; href: string }>[] = [];
spine.each((item: SpineItem) => { spine.each((item: SpineItem) => {
const url = item.href || ''; const url = item.href || '';
if (!url) return; if (!url) return;
//console.log('Extracting text from section:', item as SpineItem);
const promise = book.load(url) const promise = book.load(url)
.then((section) => (section as Document)) .then((section) => (section as Document))
.then((section) => { .then((section) => ({
const textContent = section.body.textContent || ''; text: section.body.textContent || '',
return textContent; href: url
}) }))
.catch((err) => { .catch((err) => {
console.error(`Error loading section ${url}:`, err); console.error(`Error loading section ${url}:`, err);
return ''; return { text: '', href: url };
}); });
promises.push(promise); promises.push(promise);
}); });
const textArray = await Promise.all(promises); const textArray = await Promise.all(promises);
const filteredArray = textArray.filter(text => text.trim() !== ''); const filteredArray = textArray.filter(item => item.text.trim() !== '');
console.log('Extracted entire EPUB text array:', filteredArray); console.log('Extracted entire EPUB text array:', filteredArray);
return filteredArray; return filteredArray;
} catch (error) { } catch (error) {
console.error('Error extracting EPUB text:', error); console.error('Error extracting EPUB text:', error);
return ['']; return [{ text: '', href: '' }];
} }
}, []); }, []);
@ -189,29 +190,55 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
format: 'mp3' | 'm4b' = 'mp3' format: 'mp3' | 'm4b' = 'mp3'
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {
try { try {
const textArray = await extractBookText(); const sections = await extractBookText();
if (!textArray.length) throw new Error('No text content found in book'); if (!sections.length) throw new Error('No text content found in book');
// Calculate total text length for accurate progress tracking // Calculate total length for accurate progress tracking
const totalLength = textArray.reduce((sum, text) => sum + text.trim().length, 0); const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
let processedLength = 0; let processedLength = 0;
let currentTime = 0; let currentTime = 0;
// Get TOC for chapter titles if available // Get TOC for chapter titles
const chapters = tocRef.current || []; const chapters = tocRef.current || [];
const spine = bookRef.current?.spine; console.log('Chapter map:', chapters);
for (const text of textArray) { // Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map<string, string>();
// First, loop through all chapters to create the mapping
for (const chapter of chapters) {
if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim();
// For each chapter, find all matching sections
for (const section of sections) {
const sectionHref = section.href;
const sectionBaseHref = sectionHref.split('#')[0];
// If this section matches this chapter, map it
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle);
}
}
}
console.log('Section to chapter title mapping:', sectionTitleMap);
// Process each section
for (let i = 0; i < sections.length; i++) {
if (signal?.aborted) { if (signal?.aborted) {
const partialBuffer = await combineAudioChunks(audioChunks, format); const partialBuffer = await combineAudioChunks(audioChunks, format);
return partialBuffer; return partialBuffer;
} }
try { const section = sections[i];
const trimmedText = text.trim(); const trimmedText = section.text.trim();
if (!trimmedText) continue; if (!trimmedText) continue;
try {
const ttsResponse = await fetch('/api/tts', { const ttsResponse = await fetch('/api/tts', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -222,7 +249,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
text: trimmedText, text: trimmedText,
voice: voice, voice: voice,
speed: voiceSpeed, speed: voiceSpeed,
format: 'audiobook' format: format === 'm4b' ? 'aac' : 'mp3',
}), }),
signal signal
}); });
@ -236,27 +263,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new Error('Received empty audio buffer from TTS'); throw new Error('Received empty audio buffer from TTS');
} }
// Find matching chapter title from TOC if available // Get the chapter title from our pre-computed map
let chapterTitle; let chapterTitle = sectionTitleMap.get(section.href);
if (spine && chapters.length > 0) {
let spineIndex = processedLength;
let currentSpineHref: string | undefined;
spine.each((item: SpineItem) => { // If no chapter title found, use index-based naming
if (spineIndex === 0) { if (!chapterTitle) {
currentSpineHref = item.href; chapterTitle = `Unknown Section - ${i + 1}`;
}
spineIndex--;
});
const matchingChapter = chapters.find(chapter =>
chapter.href && currentSpineHref?.includes(chapter.href)
);
chapterTitle = matchingChapter?.label || `Section ${processedLength + 1}`;
} else {
chapterTitle = `Section ${processedLength + 1}`;
} }
console.log('Processed audiobook chapter title:', chapterTitle);
audioChunks.push({ audioChunks.push({
buffer: audioBuffer, buffer: audioBuffer,
title: chapterTitle, title: chapterTitle,
@ -271,8 +286,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}); });
currentTime += (audioBuffer.byteLength + 48000) / 48000; currentTime += (audioBuffer.byteLength + 48000) / 48000;
// Update progress based on processed text length
processedLength += trimmedText.length; processedLength += trimmedText.length;
onProgress((processedLength / totalLength) * 100); onProgress((processedLength / totalLength) * 100);

View file

@ -246,7 +246,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
text, text,
voice: voice, voice: voice,
speed: voiceSpeed, speed: voiceSpeed,
format: 'audiobook' format: format === 'm4b' ? 'aac' : 'mp3'
}), }),
signal signal
}); });