Contexts refactor + commenting
This commit is contained in:
parent
8f90455862
commit
dd7154cccf
2 changed files with 297 additions and 113 deletions
|
|
@ -1,3 +1,16 @@
|
||||||
|
/**
|
||||||
|
* PDF Context Provider
|
||||||
|
*
|
||||||
|
* This module provides a React context for managing PDF document functionality.
|
||||||
|
* It handles document loading, text extraction, highlighting, and integration with TTS.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - PDF document management (add/remove/load)
|
||||||
|
* - Text extraction and processing
|
||||||
|
* - Text highlighting and navigation
|
||||||
|
* - Document state management
|
||||||
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,7 +36,12 @@ 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
|
/**
|
||||||
|
* Converts PDF binary data to a data URL for display
|
||||||
|
*
|
||||||
|
* @param {Blob} pdfData - The PDF binary data
|
||||||
|
* @returns {Promise<string>} A data URL representing the PDF
|
||||||
|
*/
|
||||||
const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
|
const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
@ -33,13 +51,27 @@ const convertPDFDataToURL = (pdfData: Blob): Promise<string> => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
*/
|
||||||
interface PDFContextType {
|
interface PDFContextType {
|
||||||
|
// Documents management
|
||||||
documents: PDFDocument[];
|
documents: PDFDocument[];
|
||||||
addDocument: (file: File) => Promise<string>;
|
addDocument: (file: File) => Promise<string>;
|
||||||
getDocument: (id: string) => Promise<PDFDocument | undefined>;
|
|
||||||
removeDocument: (id: string) => Promise<void>;
|
removeDocument: (id: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
extractTextFromPDF: (pdfURL: string, currDocPage: number) => Promise<string>;
|
|
||||||
|
// Current document state
|
||||||
|
currDocURL: string | undefined;
|
||||||
|
currDocName: string | undefined;
|
||||||
|
currDocPages: number | undefined;
|
||||||
|
currDocPage: number;
|
||||||
|
currDocText: string | undefined;
|
||||||
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
|
clearCurrDoc: () => void;
|
||||||
|
|
||||||
|
// PDF functionality
|
||||||
|
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
|
||||||
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: (
|
||||||
|
|
@ -49,19 +81,24 @@ interface PDFContextType {
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
) => void;
|
) => void;
|
||||||
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
|
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
|
||||||
currDocURL: string | undefined;
|
|
||||||
currDocName: string | undefined;
|
|
||||||
currDocPages: number | undefined;
|
|
||||||
currDocPage: number;
|
|
||||||
currDocText: string | undefined;
|
|
||||||
clearCurrDoc: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the context
|
||||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDFProvider Component
|
||||||
|
*
|
||||||
|
* Main provider component that manages PDF state and functionality.
|
||||||
|
* Handles document loading, text processing, and integration with TTS.
|
||||||
|
*/
|
||||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
/**
|
||||||
|
* State Management
|
||||||
|
* - Document management
|
||||||
|
* - Current document state
|
||||||
|
* - Loading states
|
||||||
|
*/
|
||||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -78,7 +115,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
|
||||||
// Load documents from IndexedDB once DB is ready
|
/**
|
||||||
|
* Load documents from IndexedDB when the database is ready
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDocuments = async () => {
|
const loadDocuments = async () => {
|
||||||
if (!isDBReady) return;
|
if (!isDBReady) return;
|
||||||
|
|
@ -96,7 +135,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
loadDocuments();
|
loadDocuments();
|
||||||
}, [isDBReady]);
|
}, [isDBReady]);
|
||||||
|
|
||||||
// Add a new document to IndexedDB
|
/**
|
||||||
|
* Adds a new document to IndexedDB
|
||||||
|
*
|
||||||
|
* @param {File} file - The PDF file to add
|
||||||
|
* @returns {Promise<string>} The ID of the added document
|
||||||
|
*/
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const newDoc: PDFDocument = {
|
const newDoc: PDFDocument = {
|
||||||
|
|
@ -117,17 +161,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get a document by ID from IndexedDB
|
/**
|
||||||
const getDocument = useCallback(async (id: string): Promise<PDFDocument | undefined> => {
|
* Removes a document from IndexedDB
|
||||||
try {
|
*
|
||||||
return await indexedDBService.getDocument(id);
|
* @param {string} id - The ID of the document to remove
|
||||||
} catch (error) {
|
*/
|
||||||
console.error('Failed to get document:', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Remove a document by ID from IndexedDB
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await indexedDBService.removeDocument(id);
|
await indexedDBService.removeDocument(id);
|
||||||
|
|
@ -138,12 +176,23 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles successful document load
|
||||||
|
*
|
||||||
|
* @param {Object} param0 - Object containing numPages
|
||||||
|
*/
|
||||||
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
||||||
console.log('Document loaded:', numPages);
|
console.log('Document loaded:', numPages);
|
||||||
setCurrDocPages(numPages);
|
setCurrDocPages(numPages);
|
||||||
}, [setCurrDocPages]);
|
}, [setCurrDocPages]);
|
||||||
|
|
||||||
// Extract text from a PDF file
|
/**
|
||||||
|
* Extracts text content from a specific page of the PDF
|
||||||
|
*
|
||||||
|
* @param {string} pdfURL - The URL of the PDF
|
||||||
|
* @param {number} currDocPage - The page number to extract
|
||||||
|
* @returns {Promise<string>} The extracted text
|
||||||
|
*/
|
||||||
const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
|
const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const base64Data = pdfURL.split(',')[1];
|
const base64Data = pdfURL.split(',')[1];
|
||||||
|
|
@ -222,7 +271,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load curr doc text
|
/**
|
||||||
|
* Loads and processes text from the current document page
|
||||||
|
*/
|
||||||
const loadCurrDocText = useCallback(async () => {
|
const loadCurrDocText = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!currDocURL) return;
|
if (!currDocURL) return;
|
||||||
|
|
@ -234,17 +285,23 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]);
|
}, [currDocURL, currDocPage, extractTextFromPDF, setTTSText]);
|
||||||
|
|
||||||
// Update the current document text when the page changes
|
/**
|
||||||
|
* Updates the current document text when the page changes
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currDocURL) {
|
if (currDocURL) {
|
||||||
loadCurrDocText();
|
loadCurrDocText();
|
||||||
}
|
}
|
||||||
}, [currDocPage, currDocURL, loadCurrDocText]);
|
}, [currDocPage, currDocURL, loadCurrDocText]);
|
||||||
|
|
||||||
// Set curr document
|
/**
|
||||||
|
* Sets the current document based on its ID
|
||||||
|
*
|
||||||
|
* @param {string} id - The ID of the document to set
|
||||||
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const doc = await getDocument(id);
|
const doc = await indexedDBService.getDocument(id);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
const url = await convertPDFDataToURL(doc.data);
|
const url = await convertPDFDataToURL(doc.data);
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
|
|
@ -254,8 +311,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get document URL:', error);
|
console.error('Failed to get document URL:', error);
|
||||||
}
|
}
|
||||||
}, [getDocument]);
|
}, [indexedDBService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the current document state
|
||||||
|
*/
|
||||||
const clearCurrDoc = useCallback(() => {
|
const clearCurrDoc = useCallback(() => {
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocURL(undefined);
|
setCurrDocURL(undefined);
|
||||||
|
|
@ -267,7 +327,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
}, [setCurrDocPages, setTTSText]);
|
}, [setCurrDocPages, setTTSText]);
|
||||||
|
|
||||||
// Clear all highlights in the PDF viewer
|
/**
|
||||||
|
* Removes all text highlights from the PDF viewer
|
||||||
|
*/
|
||||||
const clearHighlights = useCallback(() => {
|
const clearHighlights = useCallback(() => {
|
||||||
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
|
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
|
||||||
textNodes.forEach((node) => {
|
textNodes.forEach((node) => {
|
||||||
|
|
@ -277,7 +339,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find the best text match using string similarity
|
/**
|
||||||
|
* Finds the best matching text segment using string similarity
|
||||||
|
*
|
||||||
|
* @param {Array} elements - Array of elements and their text content
|
||||||
|
* @param {string} targetText - The text to match against
|
||||||
|
* @param {number} maxCombinedLength - Maximum length of combined text to consider
|
||||||
|
*/
|
||||||
const findBestTextMatch = useCallback((
|
const findBestTextMatch = useCallback((
|
||||||
elements: Array<{ element: HTMLElement; text: string }>,
|
elements: Array<{ element: HTMLElement; text: string }>,
|
||||||
targetText: string,
|
targetText: string,
|
||||||
|
|
@ -320,7 +388,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
return bestMatch;
|
return bestMatch;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Highlight matching text in the PDF viewer with sliding context window
|
/**
|
||||||
|
* Highlights matching text in the PDF viewer
|
||||||
|
* Uses a sliding context window for improved accuracy
|
||||||
|
*
|
||||||
|
* @param {string} text - The document text
|
||||||
|
* @param {string} pattern - The pattern to highlight
|
||||||
|
* @param {RefObject} containerRef - Reference to the container element
|
||||||
|
*/
|
||||||
const highlightPattern = useCallback((text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => {
|
const highlightPattern = useCallback((text: string, pattern: string, containerRef: React.RefObject<HTMLDivElement>) => {
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
|
|
||||||
|
|
@ -381,10 +456,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [clearHighlights, findBestTextMatch]);
|
}, [clearHighlights, findBestTextMatch]);
|
||||||
|
|
||||||
// Handle text click events in the PDF viewer
|
/**
|
||||||
|
* Handles text click events in the PDF viewer
|
||||||
|
* Integrates with TTS for synchronized playback
|
||||||
|
*
|
||||||
|
* @param {MouseEvent} event - The click event
|
||||||
|
* @param {string} pdfText - The text content of the page
|
||||||
|
* @param {RefObject} containerRef - Reference to the container element
|
||||||
|
* @param {Function} stopAndPlayFromIndex - Function to control TTS playback
|
||||||
|
* @param {boolean} isProcessing - Whether TTS is currently processing
|
||||||
|
*/
|
||||||
const handleTextClick = useCallback((
|
const handleTextClick = useCallback((
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
pageText: string, // Renamed from pdfText to pageText for clarity
|
pdfText: string, // Renamed from pdfText to pageText for clarity
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
|
|
@ -422,7 +506,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
if (bestMatch.rating >= similarityThreshold) {
|
if (bestMatch.rating >= similarityThreshold) {
|
||||||
const matchText = bestMatch.text;
|
const matchText = bestMatch.text;
|
||||||
// Use pageText instead of full PDF text for sentence splitting
|
// Use pageText instead of full PDF text for sentence splitting
|
||||||
const sentences = nlp(pageText).sentences().out('array') as string[];
|
const sentences = nlp(pdfText).sentences().out('array') as string[];
|
||||||
let bestSentenceMatch = { sentence: '', rating: 0 };
|
let bestSentenceMatch = { sentence: '', rating: 0 };
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
for (const sentence of sentences) {
|
||||||
|
|
@ -436,24 +520,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
||||||
if (sentenceIndex !== -1) {
|
if (sentenceIndex !== -1) {
|
||||||
stopAndPlayFromIndex(sentenceIndex);
|
stopAndPlayFromIndex(sentenceIndex);
|
||||||
highlightPattern(pageText, bestSentenceMatch.sentence, containerRef);
|
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [highlightPattern, findBestTextMatch]);
|
}, [highlightPattern, findBestTextMatch]);
|
||||||
|
|
||||||
// Memoize the context value to prevent unnecessary re-renders
|
// Context value memoization
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
documents,
|
documents,
|
||||||
addDocument,
|
addDocument,
|
||||||
getDocument,
|
|
||||||
removeDocument,
|
removeDocument,
|
||||||
isLoading,
|
isLoading,
|
||||||
extractTextFromPDF,
|
|
||||||
highlightPattern,
|
|
||||||
clearHighlights,
|
|
||||||
handleTextClick,
|
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
currDocURL,
|
currDocURL,
|
||||||
|
|
@ -462,17 +541,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
|
highlightPattern,
|
||||||
|
clearHighlights,
|
||||||
|
handleTextClick,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
documents,
|
documents,
|
||||||
addDocument,
|
addDocument,
|
||||||
getDocument,
|
|
||||||
removeDocument,
|
removeDocument,
|
||||||
isLoading,
|
isLoading,
|
||||||
extractTextFromPDF,
|
|
||||||
highlightPattern,
|
|
||||||
clearHighlights,
|
|
||||||
handleTextClick,
|
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
currDocURL,
|
currDocURL,
|
||||||
|
|
@ -481,6 +558,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
|
highlightPattern,
|
||||||
|
clearHighlights,
|
||||||
|
handleTextClick,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -491,6 +571,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to consume the PDF context
|
||||||
|
* Ensures the context is used within a provider
|
||||||
|
*
|
||||||
|
* @throws {Error} If used outside of PDFProvider
|
||||||
|
* @returns {PDFContextType} The PDF context value
|
||||||
|
*/
|
||||||
export function usePDF() {
|
export function usePDF() {
|
||||||
const context = useContext(PDFContext);
|
const context = useContext(PDFContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
||||||
|
|
@ -48,33 +49,28 @@ type AudioContextType = typeof window extends undefined
|
||||||
* Interface defining all available methods and properties in the TTS context
|
* Interface defining all available methods and properties in the TTS context
|
||||||
*/
|
*/
|
||||||
interface TTSContextType {
|
interface TTSContextType {
|
||||||
|
// Playback state
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentText: string;
|
isProcessing: boolean;
|
||||||
|
currentSentence: string;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
currDocPage: number;
|
||||||
|
currDocPages: number | undefined;
|
||||||
|
|
||||||
|
// Voice settings
|
||||||
|
availableVoices: string[];
|
||||||
|
|
||||||
|
// Control functions
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
skipForward: () => void;
|
skipForward: () => void;
|
||||||
skipBackward: () => void;
|
skipBackward: () => void;
|
||||||
setText: (text: string) => void;
|
|
||||||
currentSentence: string;
|
|
||||||
audioQueue: AudioBuffer[];
|
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
stopAndPlayFromIndex: (index: number) => void;
|
stopAndPlayFromIndex: (index: number) => void;
|
||||||
sentences: string[];
|
setText: (text: string) => void;
|
||||||
isProcessing: boolean;
|
|
||||||
setIsProcessing: (value: boolean) => void;
|
|
||||||
setIsPlaying: (value: boolean) => void;
|
|
||||||
speed: number;
|
|
||||||
setSpeed: (speed: number) => void;
|
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
|
||||||
voice: string;
|
|
||||||
setVoice: (voice: string) => void;
|
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
|
||||||
availableVoices: string[];
|
|
||||||
currentIndex: number;
|
|
||||||
setCurrentIndex: (index: number) => void;
|
|
||||||
currDocPage: number;
|
|
||||||
currDocPages: number | undefined;
|
|
||||||
setCurrDocPages: (num: number | undefined) => void;
|
setCurrDocPages: (num: number | undefined) => void;
|
||||||
incrementPage: (num?: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
skipToPage: (page: number) => void;
|
skipToPage: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,14 +123,15 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [nextPageLoading, setNextPageLoading] = useState(false);
|
const [nextPageLoading, setNextPageLoading] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio Cache
|
* Cache for storing audio buffers using LRU (Least Recently Used) strategy
|
||||||
* LRU cache to store processed audio buffers and improve performance
|
|
||||||
*/
|
*/
|
||||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text Processing Functions
|
* Sets the current text and splits it into sentences
|
||||||
* Handle text input and sentence splitting
|
*
|
||||||
|
* @param {string} text - The text to be processed
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const setText = useCallback((text: string) => {
|
const setText = useCallback((text: string) => {
|
||||||
setCurrentText(text);
|
setCurrentText(text);
|
||||||
|
|
@ -148,6 +145,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setNextPageLoading(false);
|
setNextPageLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the current audio playback and clears the active Howl instance
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const abortAudio = useCallback(() => {
|
const abortAudio = useCallback(() => {
|
||||||
if (activeHowl) {
|
if (activeHowl) {
|
||||||
activeHowl.stop();
|
activeHowl.stop();
|
||||||
|
|
@ -156,8 +158,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playback Control Functions
|
* Toggles the playback state between playing and paused
|
||||||
* Manage audio playback, navigation, and state
|
*
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
setIsPlaying((prev) => {
|
setIsPlaying((prev) => {
|
||||||
|
|
@ -170,6 +173,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
});
|
});
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates to a specific page in the document
|
||||||
|
*
|
||||||
|
* @param {number} page - The target page number
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const skipToPage = useCallback((page: number) => {
|
const skipToPage = useCallback((page: number) => {
|
||||||
abortAudio();
|
abortAudio();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|
@ -178,11 +187,23 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setCurrDocPage(page);
|
setCurrDocPage(page);
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the current page by a specified amount
|
||||||
|
*
|
||||||
|
* @param {number} [num=1] - The number of pages to increment by
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const incrementPage = useCallback((num = 1) => {
|
const incrementPage = useCallback((num = 1) => {
|
||||||
setNextPageLoading(true);
|
setNextPageLoading(true);
|
||||||
setCurrDocPage(currDocPage + num);
|
setCurrDocPage(currDocPage + num);
|
||||||
}, [currDocPage]);
|
}, [currDocPage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves to the next or previous sentence
|
||||||
|
*
|
||||||
|
* @param {boolean} [backwards=false] - Whether to move backwards
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
const advance = useCallback(async (backwards = false) => {
|
const advance = useCallback(async (backwards = false) => {
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
const nextIndex = prev + (backwards ? -1 : 1);
|
const nextIndex = prev + (backwards ? -1 : 1);
|
||||||
|
|
@ -211,6 +232,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [sentences, currDocPage, currDocPages, incrementPage]);
|
}, [sentences, currDocPage, currDocPages, incrementPage]);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves forward one sentence in the text
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const skipForward = useCallback(() => {
|
const skipForward = useCallback(() => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
|
@ -221,6 +247,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, [abortAudio, advance]);
|
}, [abortAudio, advance]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves backward one sentence in the text
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const skipBackward = useCallback(() => {
|
const skipBackward = useCallback(() => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
|
@ -232,10 +263,20 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [abortAudio, advance]);
|
}, [abortAudio, advance]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio Processing Functions
|
* Updates the voice and speed settings from the configuration
|
||||||
* Handle audio generation, caching, and playback
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateVoiceAndSpeed = useCallback(() => {
|
||||||
|
setVoice(configVoice);
|
||||||
|
setSpeed(voiceSpeed);
|
||||||
|
}, [configVoice, voiceSpeed]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes OpenAI configuration and fetches available voices
|
||||||
|
*
|
||||||
|
* This effect runs when the config is loaded or API settings change
|
||||||
*/
|
*/
|
||||||
// Initialize OpenAI instance when config loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVoices = async () => {
|
const fetchVoices = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -264,19 +305,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
});
|
});
|
||||||
fetchVoices();
|
fetchVoices();
|
||||||
|
updateVoiceAndSpeed();
|
||||||
}
|
}
|
||||||
}, [configIsLoading, openApiKey, openApiBaseUrl]);
|
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed]);
|
||||||
|
|
||||||
// Initialize AudioContext
|
/**
|
||||||
|
* Initializes the AudioContext when component mounts
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
|
||||||
* Initializes the AudioContext for text-to-speech playback.
|
|
||||||
* Creates a new AudioContext instance if one doesn't exist.
|
|
||||||
* Only runs on the client side to avoid SSR issues.
|
|
||||||
*
|
|
||||||
* Dependencies:
|
|
||||||
* - audioContext: Re-runs if the audioContext is null or changes
|
|
||||||
*/
|
|
||||||
if (typeof window !== 'undefined' && !audioContext) {
|
if (typeof window !== 'undefined' && !audioContext) {
|
||||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
if (AudioContextClass) {
|
if (AudioContextClass) {
|
||||||
|
|
@ -297,7 +333,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [audioContext]);
|
}, [audioContext]);
|
||||||
|
|
||||||
// Set up MediaSession API
|
/**
|
||||||
|
* Sets up MediaSession API for media controls
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
|
@ -313,7 +351,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [togglePlay, skipForward, skipBackward]);
|
}, [togglePlay, skipForward, skipBackward]);
|
||||||
|
|
||||||
//new function to return audio buffer with caching
|
/**
|
||||||
|
* Generates and plays audio for the current sentence
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
|
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
|
||||||
// Check if the audio is already cached
|
// Check if the audio is already cached
|
||||||
const cachedAudio = audioCacheRef.current.get(sentence);
|
const cachedAudio = audioCacheRef.current.get(sentence);
|
||||||
|
|
@ -343,6 +385,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [audioContext, voice, speed]);
|
}, [audioContext, voice, speed]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes and plays the current sentence
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
|
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
|
||||||
if (isProcessing && !preload) throw new Error('Audio is already being processed');
|
if (isProcessing && !preload) throw new Error('Audio is already being processed');
|
||||||
if (!audioContext || !openaiRef.current) throw new Error('Audio context not initialized');
|
if (!audioContext || !openaiRef.current) throw new Error('Audio context not initialized');
|
||||||
|
|
@ -356,6 +403,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
return audioBufferToURL(audioBuffer!);
|
return audioBufferToURL(audioBuffer!);
|
||||||
}, [isProcessing, audioContext, getAudio]);
|
}, [isProcessing, audioContext, getAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the current sentence with Howl
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
||||||
try {
|
try {
|
||||||
const audioUrl = await processSentence(sentence);
|
const audioUrl = await processSentence(sentence);
|
||||||
|
|
@ -408,6 +460,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [isPlaying, processSentence, advance]);
|
}, [isPlaying, processSentence, advance]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads the next sentence's audio
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const preloadNextAudio = useCallback(() => {
|
const preloadNextAudio = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
||||||
|
|
@ -418,6 +475,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [currentIndex, sentences, audioCacheRef, processSentence]);
|
}, [currentIndex, sentences, audioCacheRef, processSentence]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the current sentence's audio
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
const playAudio = useCallback(async () => {
|
const playAudio = useCallback(async () => {
|
||||||
await playSentenceWithHowl(sentences[currentIndex]);
|
await playSentenceWithHowl(sentences[currentIndex]);
|
||||||
}, [sentences, currentIndex, playSentenceWithHowl]);
|
}, [sentences, currentIndex, playSentenceWithHowl]);
|
||||||
|
|
@ -454,6 +516,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
abortAudio
|
abortAudio
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the current audio playback
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
abortAudio();
|
abortAudio();
|
||||||
|
|
@ -464,6 +531,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the current audio playback and starts playing from a specified index
|
||||||
|
*
|
||||||
|
* @param {number} index - The index to start playing from
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||||||
abortAudio();
|
abortAudio();
|
||||||
|
|
||||||
|
|
@ -471,12 +544,24 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current index without playing
|
||||||
|
*
|
||||||
|
* @param {number} index - The index to set
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
||||||
abortAudio();
|
abortAudio();
|
||||||
|
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the speed and restarts the playback
|
||||||
|
*
|
||||||
|
* @param {number} newSpeed - The new speed to set
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||||
setSpeed(newSpeed);
|
setSpeed(newSpeed);
|
||||||
updateConfigKey('voiceSpeed', newSpeed);
|
updateConfigKey('voiceSpeed', newSpeed);
|
||||||
|
|
@ -490,6 +575,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [isPlaying, abortAudio, updateConfigKey]);
|
}, [isPlaying, abortAudio, updateConfigKey]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the voice and restarts the playback
|
||||||
|
*
|
||||||
|
* @param {string} newVoice - The new voice to set
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||||||
setVoice(newVoice);
|
setVoice(newVoice);
|
||||||
updateConfigKey('voice', newVoice);
|
updateConfigKey('voice', newVoice);
|
||||||
|
|
@ -504,45 +595,51 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [isPlaying, abortAudio, updateConfigKey]);
|
}, [isPlaying, abortAudio, updateConfigKey]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context Value
|
* Provides the TTS context value to child components
|
||||||
* Aggregate all functions and state to be provided to consumers
|
|
||||||
*/
|
*/
|
||||||
const value = {
|
const value = useMemo(() => ({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentText,
|
isProcessing,
|
||||||
|
currentSentence: sentences[currentIndex] || '',
|
||||||
|
currDocPage,
|
||||||
|
currDocPages,
|
||||||
|
availableVoices,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
skipForward,
|
skipForward,
|
||||||
skipBackward,
|
skipBackward,
|
||||||
setText,
|
|
||||||
currentSentence: sentences[currentIndex] || '',
|
|
||||||
audioQueue,
|
|
||||||
stop,
|
stop,
|
||||||
setCurrentIndex: setCurrentIndexWithoutPlay,
|
|
||||||
stopAndPlayFromIndex,
|
stopAndPlayFromIndex,
|
||||||
sentences,
|
setText,
|
||||||
isProcessing,
|
setCurrDocPages,
|
||||||
setIsProcessing,
|
|
||||||
setIsPlaying,
|
|
||||||
speed,
|
|
||||||
setSpeed,
|
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
voice,
|
|
||||||
setVoice,
|
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
availableVoices,
|
skipToPage,
|
||||||
|
}), [
|
||||||
|
isPlaying,
|
||||||
|
isProcessing,
|
||||||
|
sentences,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
availableVoices,
|
||||||
|
togglePlay,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
stop,
|
||||||
|
stopAndPlayFromIndex,
|
||||||
|
setText,
|
||||||
setCurrDocPages,
|
setCurrDocPages,
|
||||||
incrementPage,
|
setSpeedAndRestart,
|
||||||
|
setVoiceAndRestart,
|
||||||
skipToPage,
|
skipToPage,
|
||||||
};
|
]);
|
||||||
|
|
||||||
// Render provider with value
|
|
||||||
if (configIsLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the TTS context provider with its children
|
||||||
|
*
|
||||||
|
* @param {ReactNode} children - Child components to be wrapped
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<TTSContext.Provider value={value}>
|
<TTSContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue