This commit is contained in:
Richard Roberson 2025-01-25 20:52:02 -07:00
parent f585819881
commit 96f9dae3ea
4 changed files with 203 additions and 264 deletions

View file

@ -19,9 +19,8 @@ const PDFViewer = dynamic(
export default function PDFViewerPage() { export default function PDFViewerPage() {
const { id } = useParams(); const { id } = useParams();
const { getDocument } = usePDF(); const { setCurrentDocument, currDocName } = usePDF();
const { setText, stop } = useTTS(); const { setText, stop } = useTTS();
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100); const [zoomLevel, setZoomLevel] = useState<number>(100);
@ -29,12 +28,11 @@ export default function PDFViewerPage() {
useEffect(() => { useEffect(() => {
async function loadDocument() { async function loadDocument() {
try { try {
const doc = await getDocument(id as string); if (!id) {
if (!doc) {
setError('Document not found'); setError('Document not found');
return; return;
} }
setDocument(doc); setCurrentDocument(id as string);
} catch (err) { } catch (err) {
console.error('Error loading document:', err); console.error('Error loading document:', err);
setError('Failed to load document'); setError('Failed to load document');
@ -44,7 +42,7 @@ export default function PDFViewerPage() {
} }
loadDocument(); loadDocument();
}, [id, getDocument]); }, [id, setCurrentDocument]);
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200)); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200));
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
@ -107,7 +105,7 @@ export default function PDFViewerPage() {
</div> </div>
</div> </div>
<h1 className="mr-2 text-md font-semibold text-foreground"> <h1 className="mr-2 text-md font-semibold text-foreground">
{isLoading ? 'Loading...' : document?.name} {isLoading ? 'Loading...' : currDocName}
</h1> </h1>
</div> </div>
</div> </div>
@ -116,7 +114,7 @@ export default function PDFViewerPage() {
<PDFSkeleton /> <PDFSkeleton />
</div> </div>
) : ( ) : (
<PDFViewer pdfData={document?.data} zoomLevel={zoomLevel} /> <PDFViewer zoomLevel={zoomLevel} />
)} )}
</> </>
); );

View file

@ -13,19 +13,31 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
interface PDFViewerProps { interface PDFViewerProps {
pdfData: Blob | undefined;
zoomLevel: number; zoomLevel: number;
} }
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) { export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>();
const [containerWidth, setContainerWidth] = useState<number>(0); const [containerWidth, setContainerWidth] = useState<number>(0);
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing, isPlaying, currentIndex, sentences, stop } = useTTS();
const [pdfText, setPdfText] = useState('');
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
const [loadingError, setLoadingError] = useState<string>();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { extractTextFromPDF, highlightPattern, clearHighlights, handleTextClick } = usePDF();
// TTS context
const {
currentSentence,
stopAndPlayFromIndex,
isProcessing
} = useTTS();
// PDF context
const {
highlightPattern,
clearHighlights,
handleTextClick,
onDocumentLoadSuccess,
currDocURL,
currDocPages,
currDocText,
currDocPage,
} = usePDF();
// Add static styles once during component initialization // Add static styles once during component initialization
const styleElement = document.createElement('style'); const styleElement = document.createElement('style');
@ -47,58 +59,6 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
}; };
}, [styleElement]); }, [styleElement]);
useEffect(() => {
/*
* Converts PDF blob to a data URL for display.
* Cleans up by clearing the data URL when component unmounts.
*
* Dependencies:
* - pdfData: Re-run when the PDF blob changes to convert it to a new data URL
*/
if (!pdfData) return;
const reader = new FileReader();
reader.onload = () => {
setPdfDataUrl(reader.result as string);
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
setLoadingError('Failed to load PDF');
};
reader.readAsDataURL(pdfData);
return () => {
setPdfDataUrl(undefined);
};
}, [pdfData]);
useEffect(() => {
/*
* Extracts text content from the PDF once it's loaded.
* Sets the extracted text for both display and text-to-speech.
*
* Dependencies:
* - pdfDataUrl: Re-run when the data URL is ready
* - extractTextFromPDF: Function from context that could change
* - setText: Function from context that could change
* - pdfData: Source PDF blob that's being processed
*/
if (!pdfDataUrl || !pdfData) return;
const loadPdfText = async () => {
try {
const text = await extractTextFromPDF(pdfData);
setPdfText(text);
setText(text);
} catch (error) {
console.error('Error loading PDF text:', error);
setLoadingError('Failed to extract PDF text');
}
};
loadPdfText();
}, [pdfDataUrl, extractTextFromPDF, setText, pdfData]);
useEffect(() => { useEffect(() => {
/* /*
* Sets up click event listeners for text selection in the PDF. * Sets up click event listeners for text selection in the PDF.
@ -111,10 +71,11 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
*/ */
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
if (!currDocText) return;
const handleClick = (event: MouseEvent) => handleTextClick( const handleClick = (event: MouseEvent) => handleTextClick(
event, event,
pdfText, currDocText,
containerRef as RefObject<HTMLDivElement>, containerRef as RefObject<HTMLDivElement>,
stopAndPlayFromIndex, stopAndPlayFromIndex,
isProcessing isProcessing
@ -123,7 +84,7 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
return () => { return () => {
container.removeEventListener('click', handleClick); container.removeEventListener('click', handleClick);
}; };
}, [pdfText, handleTextClick, stopAndPlayFromIndex, isProcessing]); }, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
useEffect(() => { useEffect(() => {
/* /*
@ -136,25 +97,27 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
* - highlightPattern: Function from context that could change * - highlightPattern: Function from context that could change
* - clearHighlights: Function from context that could change * - clearHighlights: Function from context that could change
*/ */
if (!currDocText) return;
const highlightTimeout = setTimeout(() => { const highlightTimeout = setTimeout(() => {
if (containerRef.current) { if (containerRef.current) {
highlightPattern(pdfText, currentSentence || '', containerRef as RefObject<HTMLDivElement>); highlightPattern(currDocText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
} }
}, 100); }, 200);
return () => { return () => {
clearTimeout(highlightTimeout); clearTimeout(highlightTimeout);
clearHighlights(); clearHighlights();
}; };
}, [pdfText, currentSentence, highlightPattern, clearHighlights]); }, [currDocText, currentSentence, highlightPattern, clearHighlights]);
// Add scale calculation function // Add scale calculation function
const calculateScale = (pageWidth: number = 595) => { // 595 is default PDF width in points const calculateScale = useCallback((pageWidth = 595): number => {
const margin = 24; // 24px padding on each side const margin = 24; // 24px padding on each side
const targetWidth = containerWidth - margin; const targetWidth = containerWidth - margin;
const baseScale = targetWidth / pageWidth; const baseScale = targetWidth / pageWidth;
return baseScale * (zoomLevel / 100); return baseScale * (zoomLevel / 100);
}; }, [containerWidth, zoomLevel]);
// Add resize observer effect // Add resize observer effect
useEffect(() => { useEffect(() => {
@ -171,121 +134,22 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
}
const [currentPage, setCurrentPage] = useState(1);
const handlePageChange = useCallback(async (pageNumber: number) => {
if (pageNumber < 1 || pageNumber > (numPages || 1)) return;
// Stop current playback and reset states
if (isPlaying) {
stop();
}
setCurrentPage(pageNumber);
// Extract text from the new page
if (pdfData) {
try {
const pdf = await pdfjs.getDocument(pdfDataUrl!).promise;
const page = await pdf.getPage(pageNumber);
const textContent = await page.getTextContent();
const textItems = textContent.items.filter((item): item is TextItem =>
'str' in item && 'transform' in item
);
// Process text items to maintain reading order
const lines: TextItem[][] = [];
let currentLine: TextItem[] = [];
let currentY: number | null = null;
const tolerance = 2;
textItems.forEach((item) => {
const y = item.transform[5];
if (currentY === null) {
currentY = y;
currentLine.push(item);
} else if (Math.abs(y - currentY) < tolerance) {
currentLine.push(item);
} else {
lines.push(currentLine);
currentLine = [item];
currentY = y;
}
});
lines.push(currentLine);
// Build page text
let fullText = '';
for (const line of lines) {
line.sort((a, b) => a.transform[4] - b.transform[4]);
let lineText = '';
let prevItem: TextItem | null = null;
for (const item of line) {
if (!prevItem) {
lineText = item.str;
} else {
const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0);
const currentStartX = item.transform[4];
const space = currentStartX - prevEndX;
if (space > ((item.width ?? 0) * 0.3)) {
lineText += ' ' + item.str;
} else {
lineText += item.str;
}
}
prevItem = item;
}
fullText += lineText + ' ';
}
setText(fullText.trim()); // Update TTS with current page text
// // Wait for text processing before resuming playback
// await new Promise(resolve => setTimeout(resolve, 300));
// if (wasPlaying) {
// stopAndPlayFromIndex(0);
// }
} catch (error) {
console.error('Error extracting page text:', error);
}
}
}, [isPlaying, pdfData, pdfDataUrl, numPages, setText, stop]);
// Auto-advance to next page when TTS reaches the end
useEffect(() => {
if (!isPlaying && currentIndex >= sentences.length - 1 && currentPage < (numPages || 1)) {
const timer = setTimeout(() => {
handlePageChange(currentPage + 1);
}, 500); // Longer delay to ensure states are settled
return () => clearTimeout(timer);
}
}, [isPlaying, currentIndex, sentences.length, currentPage, numPages, handlePageChange]);
return ( return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6"> <div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
{loadingError ? (
<div className="text-red-500 mb-4">{loadingError}</div>
) : null}
<Document <Document
loading={<PDFSkeleton />} loading={<PDFSkeleton />}
noData={<PDFSkeleton />} noData={<PDFSkeleton />}
file={pdfDataUrl} file={currDocURL}
onLoadSuccess={(pdf) => { onLoadSuccess={(pdf) => {
onDocumentLoadSuccess(pdf); onDocumentLoadSuccess(pdf);
handlePageChange(1); // Load first page text //handlePageChange(1); // Load first page text
}} }}
className="flex flex-col items-center m-0" className="flex flex-col items-center m-0"
> >
<div> <div>
<div className="flex justify-center"> <div className="flex justify-center">
<Page <Page
pageNumber={currentPage} pageNumber={currDocPage}
renderAnnotationLayer={true} renderAnnotationLayer={true}
renderTextLayer={true} renderTextLayer={true}
className="shadow-lg" className="shadow-lg"
@ -295,9 +159,9 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
</div> </div>
</Document> </Document>
<TTSPlayer <TTSPlayer
currentPage={currentPage} currentPage={currDocPage}
numPages={numPages} numPages={currDocPages}
onPageChange={handlePageChange} onPageChange={() => {}}
/> />
</div> </div>
); );

View file

@ -18,10 +18,21 @@ import nlp from 'compromise';
// Add the correct type import // Add the correct type import
import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { useTTS } from '@/contexts/TTSContext';
// Set worker from public directory // Set worker from public directory
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
// Convert PDF data to PDF data URL
const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(pdfData);
});
};
interface PDFContextType { interface PDFContextType {
documents: PDFDocument[]; documents: PDFDocument[];
addDocument: (file: File) => Promise<string>; addDocument: (file: File) => Promise<string>;
@ -29,7 +40,7 @@ interface PDFContextType {
removeDocument: (id: string) => Promise<void>; removeDocument: (id: string) => Promise<void>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
extractTextFromPDF: (pdfData: Blob) => Promise<string>; extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise<string>;
highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => void; highlightPattern: (text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => void;
clearHighlights: () => void; clearHighlights: () => void;
handleTextClick: ( handleTextClick: (
@ -39,16 +50,36 @@ interface PDFContextType {
stopAndPlayFromIndex: (index: number) => void, stopAndPlayFromIndex: (index: number) => void,
isProcessing: boolean isProcessing: boolean
) => void; ) => void;
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
convertPDFDataToURL: (pdfData: Blob) => Promise<string>;
setCurrentDocument: (id: string) => Promise<void>;
currDocURL: string | undefined;
currDocName: string | undefined;
currDocPages: number | undefined;
currDocPage: number;
currDocText: string | undefined;
} }
const PDFContext = createContext<PDFContextType | undefined>(undefined); const PDFContext = createContext<PDFContextType | undefined>(undefined);
export function PDFProvider({ children }: { children: ReactNode }) { export function PDFProvider({ children }: { children: ReactNode }) {
const { isDBReady } = useConfig();
const [documents, setDocuments] = useState<PDFDocument[]>([]); const [documents, setDocuments] = useState<PDFDocument[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { isDBReady } = useConfig();
const {
setText: setTTSText,
currDocPage,
currDocPages,
setCurrDocPages,
} = useTTS();
// Current document state
const [currDocURL, setCurrDocURL] = useState<string>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
// Load documents from IndexedDB once DB is ready // Load documents from IndexedDB once DB is ready
useEffect(() => { useEffect(() => {
const loadDocuments = async () => { const loadDocuments = async () => {
@ -117,29 +148,26 @@ export function PDFProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
console.log('Document loaded:', numPages);
setCurrDocPages(numPages);
}
// Extract text from a PDF file // Extract text from a PDF file
const extractTextFromPDF = useCallback(async (pdfData: Blob): Promise<string> => { const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
try { try {
const reader = new FileReader(); const base64Data = pdfURL.split(',')[1];
const dataUrl = await new Promise<string>((resolve, reject) => { const binaryData = atob(base64Data);
reader.onload = () => resolve(reader.result as string); const bytes = new Uint8Array(binaryData.length);
reader.onerror = () => reject(reader.error); for (let i = 0; i < binaryData.length; i++) {
reader.readAsDataURL(pdfData); bytes[i] = binaryData.charCodeAt(i);
}); }
const base64Data = dataUrl.split(',')[1]; const loadingTask = pdfjs.getDocument({ data: bytes });
const binaryData = atob(base64Data); const pdf = await loadingTask.promise;
const bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
const loadingTask = pdfjs.getDocument({ data: bytes }); // Get only the specified page
const pdf = await loadingTask.promise; const page = await pdf.getPage(currDocPage);
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent(); const textContent = await page.getTextContent();
// Filter out non-text items and assert proper type // Filter out non-text items and assert proper type
@ -197,15 +225,49 @@ export function PDFProvider({ children }: { children: ReactNode }) {
pageText += lineText + ' '; pageText += lineText + ' ';
} }
fullText += pageText + '\n'; return pageText.replace(/\s+/g, ' ').trim();
} catch (error) {
console.error('Error extracting text from PDF:', error);
throw new Error('Failed to extract text from PDF');
} }
}, []);
return fullText.replace(/\s+/g, ' ').trim(); // Load curr doc text
const loadCurrDocText = useCallback(async () => {
try {
if (!currDocURL) return;
const text = await extractTextFromPDF(currDocURL, currDocPage);
setCurrDocText(text);
setTTSText(text);
} catch (error) { } catch (error) {
console.error('Error extracting text from PDF:', error); console.error('Error loading PDF text:', error);
throw new Error('Failed to extract text from PDF'); setError('Failed to extract PDF text');
} }
}, []); }, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]);
// Update the current document text when the page changes
useEffect(() => {
if (currDocURL) {
loadCurrDocText();
}
}, [currDocPage, currDocURL, loadCurrDocText]);
// Set curr document
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
setError(null);
try {
const doc = await getDocument(id);
if (doc) {
const url = await convertPDFDataToURL(doc.data);
setCurrDocName(doc.name);
setCurrDocURL(url);
//await loadCurrDocText();
}
} catch (error) {
console.error('Failed to get document URL:', error);
setError('Failed to retrieve the document. Please try again.');
}
}, [getDocument, convertPDFDataToURL, loadCurrDocText]);
// Clear all highlights in the PDF viewer // Clear all highlights in the PDF viewer
const clearHighlights = useCallback(() => { const clearHighlights = useCallback(() => {
@ -395,6 +457,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
highlightPattern, highlightPattern,
clearHighlights, clearHighlights,
handleTextClick, handleTextClick,
onDocumentLoadSuccess,
convertPDFDataToURL,
setCurrentDocument,
currDocURL,
currDocName,
currDocPages,
currDocPage,
currDocText,
}), }),
[ [
documents, documents,
@ -407,6 +477,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
highlightPattern, highlightPattern,
clearHighlights, clearHighlights,
handleTextClick, handleTextClick,
onDocumentLoadSuccess,
convertPDFDataToURL,
setCurrentDocument,
currDocURL,
currDocName,
currDocPages,
currDocPage,
currDocText,
] ]
); );

View file

@ -51,6 +51,9 @@ interface TTSContextType {
availableVoices: string[]; availableVoices: string[];
currentIndex: number; currentIndex: number;
setCurrentIndex: (index: number) => void; setCurrentIndex: (index: number) => void;
currDocPage: number;
currDocPages: number | undefined;
setCurrDocPages: (pages: number) => void;
} }
const TTSContext = createContext<TTSContextType | undefined>(undefined); const TTSContext = createContext<TTSContextType | undefined>(undefined);
@ -67,7 +70,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const [sentences, setSentences] = useState<string[]>([]); const [sentences, setSentences] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [audioContext, setAudioContext] = useState<AudioContextType>(); const [audioContext, setAudioContext] = useState<AudioContextType>();
const currentRequestRef = useRef<AbortController | null>(null);
const [activeHowl, setActiveHowl] = useState<Howl | null>(null); const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
const [audioQueue] = useState<AudioBuffer[]>([]); const [audioQueue] = useState<AudioBuffer[]>([]);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@ -75,31 +77,33 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const [voice, setVoice] = useState('alloy'); const [voice, setVoice] = useState('alloy');
const [availableVoices, setAvailableVoices] = useState<string[]>([]); const [availableVoices, setAvailableVoices] = useState<string[]>([]);
const [currDocPage, setCurrDocPage] = useState<number>(1);
const [currDocPages, setCurrDocPages] = useState<number>();
const [nextPageLoading, setNextPageLoading] = useState(false);
// Audio cache using LRUCache with a maximum size of 50 entries // Audio cache using LRUCache with a maximum size of 50 entries
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 })); const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
const setText = useCallback((text: string) => { const setText = useCallback((text: string) => {
setCurrentText(text); setCurrentText(text);
console.log('Setting page text:', text);
const newSentences = splitIntoSentences(text); const newSentences = splitIntoSentences(text);
setSentences(newSentences); setSentences(newSentences);
setCurrentIndex(0); //setCurrentIndex(0);
setIsPlaying(false); //setIsPlaying(false);
// Clear audio cache // Clear audio cache
audioCacheRef.current.clear(); audioCacheRef.current.clear();
setNextPageLoading(false);
}, []); }, []);
const abortAudio = useCallback(() => { const abortAudio = useCallback(() => {
if (currentRequestRef.current) {
currentRequestRef.current.abort();
currentRequestRef.current = null;
}
if (activeHowl) { if (activeHowl) {
activeHowl.stop(); activeHowl.stop();
setActiveHowl(null); setActiveHowl(null);
} }
}, [currentRequestRef, activeHowl]); }, [activeHowl]);
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
setIsPlaying((prev) => { setIsPlaying((prev) => {
@ -231,10 +235,17 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
if (nextIndex < sentences.length) { if (nextIndex < sentences.length) {
console.log('Auto-advancing to next sentence:', sentences[nextIndex]); console.log('Auto-advancing to next sentence:', sentences[nextIndex]);
return nextIndex; return nextIndex;
} else if (currDocPage < currDocPages!) {
console.log('Auto-advancing to next page:', currDocPage + 1);
setNextPageLoading(true);
setCurrDocPage(currDocPage + 1);
return 0;
} }
return prev; return prev;
}); });
}, [sentences]); }, [sentences, currDocPage, currDocPages]);
//new function to return audio buffer with caching //new function to return audio buffer with caching
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => { const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
@ -273,23 +284,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Only set processing state if not preloading // Only set processing state if not preloading
if (!preload) setIsProcessing(true); if (!preload) setIsProcessing(true);
try { const cleanedSentence = preprocessSentenceForAudio(sentence);
const cleanedSentence = preprocessSentenceForAudio(sentence); const audioBuffer = await getAudio(cleanedSentence);
currentRequestRef.current = new AbortController();
const audioBuffer = await getAudio(cleanedSentence); return audioBufferToURL(audioBuffer!);
if (!currentRequestRef.current) throw new Error('Request was cancelled');
return audioBufferToURL(audioBuffer!);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('Request was cancelled');
}
throw error;
} finally {
currentRequestRef.current = null;
}
}, [isProcessing, audioContext, getAudio]); }, [isProcessing, audioContext, getAudio]);
const playSentenceWithHowl = useCallback(async (sentence: string) => { const playSentenceWithHowl = useCallback(async (sentence: string) => {
@ -299,9 +297,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
throw new Error('No audio URL generated'); throw new Error('No audio URL generated');
} }
// Set processing to false before creating the Howl instance
setIsProcessing(false);
const howl = new Howl({ const howl = new Howl({
src: [audioUrl], src: [audioUrl],
format: ['wav'], format: ['wav'],
@ -335,28 +330,28 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setActiveHowl(howl); setActiveHowl(howl);
howl.play(); howl.play();
setIsProcessing(false);
} catch (error) { } catch (error) {
console.error('Error playing TTS:', error); console.error('Error playing TTS:', error);
setActiveHowl(null); setActiveHowl(null);
setIsProcessing(false); setIsProcessing(false);
setIsPlaying(false); // Stop playback on error //setIsPlaying(false); // Stop playback on error
if (error instanceof Error && if (error instanceof Error &&
(error.message.includes('Unable to decode audio data') || (error.message.includes('Unable to decode audio data') ||
error.message.includes('EncodingError'))) { error.message.includes('EncodingError'))) {
console.log('Skipping problematic sentence:', sentence.substring(0, 50) + '...'); console.log('Skipping problematic sentence:', sentence);
advance(); advance(); // Skip problematic sentence
} }
} }
}, [isPlaying, processSentence, advance]); }, [isPlaying, processSentence, advance]);
const preloadNextAudio = useCallback(async () => { const preloadNextAudio = useCallback(() => {
try { try {
// Only preload next sentence to reduce API load
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) { if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay //await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
await processSentence(sentences[currentIndex + 1], true); // True indicates preloading processSentence(sentences[currentIndex + 1], true); // True indicates preloading
} }
} catch (error) { } catch (error) {
console.error('Error preloading next sentence:', error); console.error('Error preloading next sentence:', error);
@ -364,22 +359,21 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}, [currentIndex, sentences, audioCacheRef, processSentence]); }, [currentIndex, sentences, audioCacheRef, processSentence]);
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
//if (isPlaying && sentences[currentIndex] && !isProcessing) {
await playSentenceWithHowl(sentences[currentIndex]); await playSentenceWithHowl(sentences[currentIndex]);
//}
}, [sentences, currentIndex, playSentenceWithHowl]); }, [sentences, currentIndex, playSentenceWithHowl]);
// main driver useEffect // main driver useEffect
useEffect(() => { useEffect(() => {
if (!sentences[currentIndex]) { if (!isPlaying) return; // Don't proceed if stopped
return; // Don't proceed if no valid sentence if (isProcessing) return; // Don't proceed if processing audio
} if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
if (nextPageLoading) return; // Don't proceed if loading next page
if (activeHowl) return; // Don't proceed if audio is already playing
if (isPlaying && !isProcessing && !activeHowl) { // Play the current sentence and preload the next one if available
playAudio(); playAudio();
if (isPlaying) { // Only preload if still playing if (sentences[currentIndex + 1]) {
preloadNextAudio().catch(console.error); preloadNextAudio();
}
} }
return () => { return () => {
@ -390,6 +384,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
isProcessing, isProcessing,
currentIndex, currentIndex,
sentences, sentences,
activeHowl,
nextPageLoading,
playAudio, playAudio,
preloadNextAudio, preloadNextAudio,
abortAudio abortAudio
@ -477,6 +473,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setVoiceAndRestart, setVoiceAndRestart,
availableVoices, availableVoices,
currentIndex, currentIndex,
currDocPage,
currDocPages,
setCurrDocPages,
}; };
if (configIsLoading) { if (configIsLoading) {