Further audiobook extraction optimizations
This commit is contained in:
parent
568c9f2095
commit
e3370f7526
5 changed files with 73 additions and 58 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue