Implement PDF audiobook generation with configurable margins for text extraction
This commit is contained in:
parent
92220de5ea
commit
9357dddf3a
2 changed files with 130 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue