Implement PDF audiobook generation with configurable margins for text extraction

This commit is contained in:
Richard Roberson 2025-02-25 16:11:08 -07:00
parent 92220de5ea
commit 9357dddf3a
2 changed files with 130 additions and 8 deletions

View file

@ -1,10 +1,11 @@
'use client'; 'use client';
import { Fragment, useState, useRef, useEffect } from 'react'; import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { useConfig, ViewType } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { useEPUB } from '@/contexts/EPUBContext'; import { useEPUB } from '@/contexts/EPUBContext';
import { usePDF } from '@/contexts/PDFContext';
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;
@ -32,6 +33,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
updateConfigKey updateConfigKey
} = useConfig(); } = useConfig();
const { createFullAudioBook } = useEPUB(); const { createFullAudioBook } = useEPUB();
const { createFullAudioBook: createPDFAudioBook } = usePDF();
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [localMargins, setLocalMargins] = useState({ const [localMargins, setLocalMargins] = useState({
@ -70,13 +72,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
} }
}; };
const handleStartGeneration = async () => { const handleStartGeneration = useCallback(async () => {
setIsGenerating(true); setIsGenerating(true);
setProgress(0); setProgress(0);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
try { try {
const audioBuffer = await createFullAudioBook( const audioBuffer = epub ? await createFullAudioBook(
(progress) => setProgress(progress),
abortControllerRef.current.signal
) : await createPDFAudioBook(
(progress) => setProgress(progress), (progress) => setProgress(progress),
abortControllerRef.current.signal abortControllerRef.current.signal
); );
@ -102,7 +107,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
setProgress(0); setProgress(0);
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}; }, [createFullAudioBook, createPDFAudioBook, epub]);
const handleCancel = () => { const handleCancel = () => {
if (abortControllerRef.current) { if (abortControllerRef.current) {
@ -148,7 +153,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleStartGeneration} onClick={handleStartGeneration}
> >
Export to Audiobook (experimental) Export to audiobook.mp3 (experimental)
</Button> </Button>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -177,7 +182,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
{!epub && <div className="space-y-6"> {!epub && <div className="space-y-6">
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<label className="block text-sm font-medium text-foreground mb-4"> <label className="block text-sm font-medium text-foreground mb-4">
Adjust extraction margins (experimental) Text extraction margins
</label> </label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{/* Header Margin */} {/* Header Margin */}
@ -261,7 +266,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
</div> </div>
</div> </div>
<p className="text-xs text-muted mt-2"> <p className="text-xs text-muted mt-2">
Adjust margins to exclude content from edges of the page during text extraction Adjust margins to exclude content from edges of the page during text extraction (experimental)
</p> </p>
</div> </div>
<Listbox <Listbox

View file

@ -36,6 +36,7 @@ import {
} from '@/utils/pdf'; } from '@/utils/pdf';
import type { PDFDocumentProxy } from 'pdfjs-dist'; import type { PDFDocumentProxy } from 'pdfjs-dist';
import { useParams } from 'next/navigation';
/** /**
* Interface defining all available methods and properties in the PDF context * Interface defining all available methods and properties in the PDF context
@ -62,6 +63,7 @@ interface PDFContextType {
stopAndPlayFromIndex: (index: number) => void, stopAndPlayFromIndex: (index: number) => void,
isProcessing: boolean isProcessing: boolean
) => void; ) => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal) => Promise<ArrayBuffer>;
} }
// Create the context // Create the context
@ -88,7 +90,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
headerMargin, headerMargin,
footerMargin, footerMargin,
leftMargin, leftMargin,
rightMargin rightMargin,
apiKey,
baseUrl,
voiceSpeed,
voice,
} = useConfig(); } = useConfig();
// Current document state // Current document state
@ -177,6 +183,115 @@ export function PDFProvider({ children }: { children: ReactNode }) {
stop(); stop();
}, [setCurrDocPages, stop]); }, [setCurrDocPages, stop]);
/**
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
* @param {Function} onProgress - Callback for progress updates
* @param {AbortSignal} signal - Optional signal for cancellation
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
*/
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal
): Promise<ArrayBuffer> => {
try {
if (!pdfDocument) {
throw new Error('No PDF document loaded');
}
// Create an array to store all audio chunks
const audioChunks: ArrayBuffer[] = [];
const totalPages = pdfDocument.numPages;
let processedPages = 0;
// Process each page of the PDF
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
// Check for cancellation
if (signal?.aborted) {
const partialBuffer = combineAudioChunks(audioChunks);
return partialBuffer;
}
// Extract text from the current page
const text = await extractTextFromPDF(pdfDocument, pageNum, {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
if (!text.trim()) {
processedPages++;
continue;
}
try {
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
},
body: JSON.stringify({
text: text.trim(),
voice: voice,
speed: voiceSpeed,
}),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const audioBuffer = await ttsResponse.arrayBuffer();
if (audioBuffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
audioChunks.push(audioBuffer);
// Add a small pause between pages (1s of silence)
const silenceBuffer = new ArrayBuffer(48000);
audioChunks.push(silenceBuffer);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted');
const partialBuffer = combineAudioChunks(audioChunks);
return partialBuffer;
}
console.error('Error processing page:', error);
}
processedPages++;
onProgress((processedPages / totalPages) * 100);
}
if (audioChunks.length === 0) {
throw new Error('No audio was generated from the PDF content');
}
return combineAudioChunks(audioChunks);
} catch (error) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed]);
// Helper function to combine audio chunks
const combineAudioChunks = (audioChunks: ArrayBuffer[]): ArrayBuffer => {
const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
const combinedBuffer = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of audioChunks) {
combinedBuffer.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
return combinedBuffer.buffer;
};
// Context value memoization // Context value memoization
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
@ -192,6 +307,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
clearHighlights, clearHighlights,
handleTextClick, handleTextClick,
pdfDocument, pdfDocument,
createFullAudioBook,
}), }),
[ [
onDocumentLoadSuccess, onDocumentLoadSuccess,
@ -203,6 +319,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
currDocText, currDocText,
clearCurrDoc, clearCurrDoc,
pdfDocument, pdfDocument,
createFullAudioBook,
] ]
); );