Merge pull request #4 from richardr1126/single-page-viewer
Single page viewer
This commit is contained in:
commit
dc95ea82bb
12 changed files with 779 additions and 755 deletions
|
|
@ -4,9 +4,8 @@ import dynamic from 'next/dynamic';
|
|||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
||||
import TTSPlayer from '@/components/TTSPlayer';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
|
||||
// Dynamic import for client-side rendering only
|
||||
|
|
@ -20,32 +19,32 @@ const PDFViewer = dynamic(
|
|||
|
||||
export default function PDFViewerPage() {
|
||||
const { id } = useParams();
|
||||
const { getDocument } = usePDF();
|
||||
const { setText, stop } = useTTS();
|
||||
const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null);
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
|
||||
const { stop } = useTTS();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDocument() {
|
||||
try {
|
||||
const doc = await getDocument(id as string);
|
||||
if (!doc) {
|
||||
setError('Document not found');
|
||||
return;
|
||||
}
|
||||
setDocument(doc);
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
setError('Failed to load document');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||
console.log('Loading new document (from page.tsx)');
|
||||
try {
|
||||
if (!id) {
|
||||
setError('Document not found');
|
||||
return;
|
||||
}
|
||||
setCurrentDocument(id as string);
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
setError('Failed to load document');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, id, setCurrentDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocument();
|
||||
}, [id, getDocument]);
|
||||
}, [loadDocument]);
|
||||
|
||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 200));
|
||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||
|
|
@ -56,10 +55,7 @@ export default function PDFViewerPage() {
|
|||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {
|
||||
setText('');
|
||||
stop();
|
||||
}}
|
||||
onClick={() => {clearCurrDoc(); stop();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -73,16 +69,12 @@ export default function PDFViewerPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<TTSPlayer />
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {
|
||||
setText('');
|
||||
stop();
|
||||
}}
|
||||
onClick={() => {clearCurrDoc(); stop();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -109,7 +101,7 @@ export default function PDFViewerPage() {
|
|||
</div>
|
||||
</div>
|
||||
<h1 className="mr-2 text-md font-semibold text-foreground">
|
||||
{isLoading ? 'Loading...' : document?.name}
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,7 +110,7 @@ export default function PDFViewerPage() {
|
|||
<PDFSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<PDFViewer pdfData={document?.data} zoomLevel={zoomLevel} />
|
||||
<PDFViewer zoomLevel={zoomLevel} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
|
@ -8,21 +8,34 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { PDFSkeleton } from './PDFSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
|
||||
interface PDFViewerProps {
|
||||
pdfData: Blob | undefined;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing } = useTTS();
|
||||
const [pdfText, setPdfText] = useState('');
|
||||
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
||||
const [loadingError, setLoadingError] = useState<string>();
|
||||
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
|
||||
const styleElement = document.createElement('style');
|
||||
|
|
@ -44,58 +57,6 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
|||
};
|
||||
}, [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(() => {
|
||||
/*
|
||||
* Sets up click event listeners for text selection in the PDF.
|
||||
|
|
@ -108,10 +69,11 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
|||
*/
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!currDocText) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => handleTextClick(
|
||||
event,
|
||||
pdfText,
|
||||
currDocText,
|
||||
containerRef as RefObject<HTMLDivElement>,
|
||||
stopAndPlayFromIndex,
|
||||
isProcessing
|
||||
|
|
@ -120,7 +82,7 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
|||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [pdfText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
|
||||
}, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
|
|
@ -133,25 +95,27 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
|||
* - highlightPattern: Function from context that could change
|
||||
* - clearHighlights: Function from context that could change
|
||||
*/
|
||||
if (!currDocText) return;
|
||||
|
||||
const highlightTimeout = setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
highlightPattern(pdfText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
||||
highlightPattern(currDocText, currentSentence || '', containerRef as RefObject<HTMLDivElement>);
|
||||
}
|
||||
}, 100);
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(highlightTimeout);
|
||||
clearHighlights();
|
||||
};
|
||||
}, [pdfText, currentSentence, highlightPattern, clearHighlights]);
|
||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights]);
|
||||
|
||||
// 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 targetWidth = containerWidth - margin;
|
||||
const baseScale = targetWidth / pageWidth;
|
||||
return baseScale * (zoomLevel / 100);
|
||||
};
|
||||
}, [containerWidth, zoomLevel]);
|
||||
|
||||
// Add resize observer effect
|
||||
useEffect(() => {
|
||||
|
|
@ -168,48 +132,34 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
||||
setNumPages(numPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6"
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
{loadingError ? (
|
||||
<div className="text-red-500 mb-4">{loadingError}</div>
|
||||
) : null}
|
||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
||||
<Document
|
||||
loading={<PDFSkeleton />}
|
||||
noData={<PDFSkeleton />}
|
||||
file={pdfDataUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
file={currDocURL}
|
||||
onLoadSuccess={(pdf) => {
|
||||
onDocumentLoadSuccess(pdf);
|
||||
//handlePageChange(1); // Load first page text
|
||||
}}
|
||||
className="flex flex-col items-center m-0"
|
||||
>
|
||||
{Array.from(
|
||||
new Array(numPages),
|
||||
(el, index) => (
|
||||
<div key={`page_${index + 1}`}>
|
||||
<div className="bg-offbase my-4 px-2 py-0.5 rounded-full w-fit">
|
||||
<p className="text-xs">
|
||||
{index + 1} / {numPages}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Page
|
||||
pageNumber={index + 1}
|
||||
renderAnnotationLayer={true}
|
||||
renderTextLayer={true}
|
||||
className="shadow-lg"
|
||||
scale={calculateScale()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<Page
|
||||
pageNumber={currDocPage}
|
||||
renderAnnotationLayer={true}
|
||||
renderTextLayer={true}
|
||||
className="shadow-lg"
|
||||
scale={calculateScale()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Document>
|
||||
<TTSPlayer
|
||||
currentPage={currDocPage}
|
||||
numPages={currDocPages}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/components/Spinner.tsx
Normal file
8
src/components/Spinner.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Loading spinner component
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-foreground border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
SkipForwardIcon,
|
||||
SkipBackwardIcon,
|
||||
ChevronUpDownIcon,
|
||||
} from './icons/Icons';
|
||||
|
||||
// Loading spinner component
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-foreground border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const speedOptions = [
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 0.75, label: '0.75x' },
|
||||
{ value: 1, label: '1x' },
|
||||
{ value: 1.25, label: '1.25x' },
|
||||
{ value: 1.5, label: '1.5x' },
|
||||
{ value: 1.75, label: '1.75x' },
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 2.5, label: '2.5x' },
|
||||
{ value: 3, label: '3x' },
|
||||
];
|
||||
|
||||
export default function TTSPlayer() {
|
||||
const {
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
isProcessing,
|
||||
speed,
|
||||
setSpeedAndRestart,
|
||||
voice,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
} = useTTS();
|
||||
|
||||
//console.log(availableVoices);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-4 py-1 flex items-center space-x-1 relative">
|
||||
<div className="relative">
|
||||
<Listbox value={speed} onChange={setSpeedAndRestart}>
|
||||
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
|
||||
<span>{speed}x</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute bottom-full mb-1 w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{speedOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''
|
||||
} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={skipBackward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Skip backward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={togglePlay}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={skipForward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Skip forward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
||||
</Button>
|
||||
|
||||
|
||||
<div className="relative">
|
||||
<Listbox value={voice} onChange={setVoiceAndRestart}>
|
||||
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
|
||||
<span>{voice}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute bottom-full mb-1 w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{availableVoices.map((voiceId) => (
|
||||
<ListboxOption
|
||||
key={voiceId}
|
||||
value={voiceId}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
<span>{voiceId}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/player/Navigator.tsx
Normal file
42
src/components/player/Navigator.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Button } from '@headlessui/react';
|
||||
|
||||
export const Navigator = ({ currentPage, numPages, skipToPage }: {
|
||||
currentPage: number;
|
||||
numPages: number | undefined;
|
||||
skipToPage: (page: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Page back */}
|
||||
<Button
|
||||
onClick={() => skipToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{/* Page number */}
|
||||
<div className="bg-offbase px-2 py-0.5 rounded-full">
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
{currentPage} / {numPages || 1}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page forward */}
|
||||
<Button
|
||||
onClick={() => skipToPage(currentPage + 1)}
|
||||
disabled={currentPage >= (numPages || 1)}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/player/SpeedControl.tsx
Normal file
49
src/components/player/SpeedControl.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
|
||||
const speedOptions = [
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 0.75, label: '0.75x' },
|
||||
{ value: 1, label: '1x' },
|
||||
{ value: 1.25, label: '1.25x' },
|
||||
{ value: 1.5, label: '1.5x' },
|
||||
{ value: 1.75, label: '1.75x' },
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 2.5, label: '2.5x' },
|
||||
{ value: 3, label: '3x' },
|
||||
];
|
||||
|
||||
export const SpeedControl = ({ speed, setSpeedAndRestart }: {
|
||||
speed: number;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Listbox value={speed} onChange={setSpeedAndRestart}>
|
||||
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
|
||||
<span>{speed}x</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute bottom-full mb-1 w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{speedOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''
|
||||
} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/player/TTSPlayer.tsx
Normal file
75
src/components/player/TTSPlayer.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client';
|
||||
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button } from '@headlessui/react';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
SkipForwardIcon,
|
||||
SkipBackwardIcon,
|
||||
} from '@/components/icons/Icons';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { VoicesControl } from '@/components/player/VoicesControl';
|
||||
import { SpeedControl } from '@/components/player/SpeedControl';
|
||||
import { Navigator } from '@/components/player/Navigator';
|
||||
|
||||
export default function TTSPlayer({ currentPage, numPages }: {
|
||||
currentPage: number;
|
||||
numPages: number | undefined;
|
||||
}) {
|
||||
const {
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
isProcessing,
|
||||
speed,
|
||||
setSpeedAndRestart,
|
||||
voice,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
skipToPage,
|
||||
} = useTTS();
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 transition-opacity duration-300`}>
|
||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-4 py-1 flex items-center space-x-1 relative">
|
||||
{/* Speed control */}
|
||||
<SpeedControl speed={speed} setSpeedAndRestart={setSpeedAndRestart} />
|
||||
|
||||
{/* Page Navigation */}
|
||||
<Navigator currentPage={currentPage} numPages={numPages} skipToPage={skipToPage} />
|
||||
|
||||
{/* Playback Controls */}
|
||||
<Button
|
||||
onClick={skipBackward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Skip backward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={togglePlay}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={skipForward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
aria-label="Skip forward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
||||
</Button>
|
||||
|
||||
{/* Voice control */}
|
||||
<VoicesControl voice={voice} availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/player/VoicesControl.tsx
Normal file
37
src/components/player/VoicesControl.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
|
||||
export const VoicesControl = ({ voice, availableVoices, setVoiceAndRestart }: {
|
||||
voice: string;
|
||||
availableVoices: string[];
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Listbox value={voice} onChange={setVoiceAndRestart}>
|
||||
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
|
||||
<span>{voice}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute bottom-full mb-1 w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{availableVoices.map((voiceId) => (
|
||||
<ListboxOption
|
||||
key={voiceId}
|
||||
value={voiceId}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
<span>{voiceId}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,10 +18,21 @@ import nlp from 'compromise';
|
|||
// Add the correct type import
|
||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
|
||||
// Set worker from public directory
|
||||
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 {
|
||||
documents: PDFDocument[];
|
||||
addDocument: (file: File) => Promise<string>;
|
||||
|
|
@ -29,7 +40,7 @@ interface PDFContextType {
|
|||
removeDocument: (id: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
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;
|
||||
clearHighlights: () => void;
|
||||
handleTextClick: (
|
||||
|
|
@ -39,21 +50,41 @@ interface PDFContextType {
|
|||
stopAndPlayFromIndex: (index: number) => void,
|
||||
isProcessing: boolean
|
||||
) => 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;
|
||||
}
|
||||
|
||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||
|
||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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
|
||||
useEffect(() => {
|
||||
const loadDocuments = async () => {
|
||||
if (!isDBReady) return;
|
||||
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const docs = await indexedDBService.getAllDocuments();
|
||||
|
|
@ -117,17 +148,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Extract text from a PDF file
|
||||
const extractTextFromPDF = useCallback(async (pdfData: Blob): Promise<string> => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(pdfData);
|
||||
});
|
||||
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
||||
console.log('Document loaded:', numPages);
|
||||
setCurrDocPages(numPages);
|
||||
}, [setCurrDocPages]);
|
||||
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
// Extract text from a PDF file
|
||||
const extractTextFromPDF = useCallback(async (pdfURL: string, currDocPage: number): Promise<string> => {
|
||||
try {
|
||||
const base64Data = pdfURL.split(',')[1];
|
||||
const binaryData = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
|
|
@ -136,77 +165,121 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const loadingTask = pdfjs.getDocument({ data: bytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
let fullText = '';
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
// Filter out non-text items and assert proper type
|
||||
const textItems = textContent.items.filter((item): item is TextItem =>
|
||||
'str' in item && 'transform' in item
|
||||
);
|
||||
|
||||
// Group text items into lines based on their vertical position
|
||||
const tolerance = 2;
|
||||
const lines: TextItem[][] = [];
|
||||
let currentLine: TextItem[] = [];
|
||||
let currentY: number | null = null;
|
||||
|
||||
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);
|
||||
|
||||
// Process each line to build text
|
||||
let pageText = '';
|
||||
for (const line of lines) {
|
||||
// Sort items horizontally within the line
|
||||
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;
|
||||
|
||||
// Add space if gap is significant, otherwise concatenate directly
|
||||
if (space > ((item.width ?? 0) * 0.3)) {
|
||||
lineText += ' ' + item.str;
|
||||
} else {
|
||||
lineText += item.str;
|
||||
}
|
||||
}
|
||||
prevItem = item;
|
||||
}
|
||||
pageText += lineText + ' ';
|
||||
// Get only the specified page
|
||||
const page = await pdf.getPage(currDocPage);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
// Filter out non-text items and assert proper type
|
||||
const textItems = textContent.items.filter((item): item is TextItem =>
|
||||
'str' in item && 'transform' in item
|
||||
);
|
||||
|
||||
// Group text items into lines based on their vertical position
|
||||
const tolerance = 2;
|
||||
const lines: TextItem[][] = [];
|
||||
let currentLine: TextItem[] = [];
|
||||
let currentY: number | null = null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
fullText += pageText + '\n';
|
||||
});
|
||||
lines.push(currentLine);
|
||||
|
||||
// Process each line to build text
|
||||
let pageText = '';
|
||||
for (const line of lines) {
|
||||
// Sort items horizontally within the line
|
||||
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;
|
||||
|
||||
// Add space if gap is significant, otherwise concatenate directly
|
||||
if (space > ((item.width ?? 0) * 0.3)) {
|
||||
lineText += ' ' + item.str;
|
||||
} else {
|
||||
lineText += item.str;
|
||||
}
|
||||
}
|
||||
prevItem = item;
|
||||
}
|
||||
pageText += lineText + ' ';
|
||||
}
|
||||
|
||||
return fullText.replace(/\s+/g, ' ').trim();
|
||||
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');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load curr doc text
|
||||
const loadCurrDocText = useCallback(async () => {
|
||||
try {
|
||||
if (!currDocURL) return;
|
||||
const text = await extractTextFromPDF(currDocURL, currDocPage);
|
||||
setCurrDocText(text);
|
||||
setTTSText(text);
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF text:', error);
|
||||
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]);
|
||||
|
||||
const clearCurrDoc = useCallback(() => {
|
||||
setCurrDocName(undefined);
|
||||
setCurrDocURL(undefined);
|
||||
setCurrDocText(undefined);
|
||||
|
||||
// Clear TTS text
|
||||
setCurrDocPages(undefined); // Goes to TTS context
|
||||
setTTSText('');
|
||||
|
||||
}, [setCurrDocPages, setTTSText]);
|
||||
|
||||
// Clear all highlights in the PDF viewer
|
||||
const clearHighlights = useCallback(() => {
|
||||
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
|
||||
|
|
@ -291,7 +364,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
// Search for the best match within the visible area first
|
||||
let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2);
|
||||
|
||||
|
||||
// If no good match found in visible area, search the entire document
|
||||
if (bestMatch.rating < 0.3) {
|
||||
bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2);
|
||||
|
|
@ -324,12 +397,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
// Handle text click events in the PDF viewer
|
||||
const handleTextClick = useCallback((
|
||||
event: MouseEvent,
|
||||
pdfText: string,
|
||||
pageText: string, // Renamed from pdfText to pageText for clarity
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
stopAndPlayFromIndex: (index: number) => void,
|
||||
isProcessing: boolean
|
||||
) => {
|
||||
if (isProcessing) return; // Don't process clicks while TTS is processing
|
||||
if (isProcessing) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
||||
|
|
@ -361,7 +434,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
if (bestMatch.rating >= similarityThreshold) {
|
||||
const matchText = bestMatch.text;
|
||||
const sentences = nlp(pdfText).sentences().out('array') as string[];
|
||||
// Use pageText instead of full PDF text for sentence splitting
|
||||
const sentences = nlp(pageText).sentences().out('array') as string[];
|
||||
let bestSentenceMatch = { sentence: '', rating: 0 };
|
||||
|
||||
for (const sentence of sentences) {
|
||||
|
|
@ -375,7 +449,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
||||
if (sentenceIndex !== -1) {
|
||||
stopAndPlayFromIndex(sentenceIndex);
|
||||
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
|
||||
highlightPattern(pageText, bestSentenceMatch.sentence, containerRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,6 +468,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
highlightPattern,
|
||||
clearHighlights,
|
||||
handleTextClick,
|
||||
onDocumentLoadSuccess,
|
||||
setCurrentDocument,
|
||||
currDocURL,
|
||||
currDocName,
|
||||
currDocPages,
|
||||
currDocPage,
|
||||
currDocText,
|
||||
clearCurrDoc,
|
||||
}),
|
||||
[
|
||||
documents,
|
||||
|
|
@ -406,6 +488,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
highlightPattern,
|
||||
clearHighlights,
|
||||
handleTextClick,
|
||||
onDocumentLoadSuccess,
|
||||
setCurrentDocument,
|
||||
currDocURL,
|
||||
currDocName,
|
||||
currDocPages,
|
||||
currDocPage,
|
||||
currDocText,
|
||||
clearCurrDoc,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import React, {
|
|||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import nlp from 'compromise';
|
||||
import OpenAI from 'openai';
|
||||
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { Howl } from 'howler';
|
||||
|
||||
// Add type declarations
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { splitIntoSentences, preprocessSentenceForAudio } from '@/services/nlp';
|
||||
import { audioBufferToURL } from '@/services/audio';
|
||||
|
||||
// Media globals
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext: typeof AudioContext;
|
||||
|
|
@ -23,7 +25,7 @@ declare global {
|
|||
|
||||
type AudioContextType = typeof window extends undefined
|
||||
? never
|
||||
: (AudioContext | null);
|
||||
: (AudioContext);
|
||||
|
||||
interface TTSContextType {
|
||||
isPlaying: boolean;
|
||||
|
|
@ -34,12 +36,12 @@ interface TTSContextType {
|
|||
setText: (text: string) => void;
|
||||
currentSentence: string;
|
||||
audioQueue: AudioBuffer[];
|
||||
currentAudioIndex: number;
|
||||
stop: () => void;
|
||||
setCurrentIndex: (index: number) => void;
|
||||
stopAndPlayFromIndex: (index: number) => void;
|
||||
sentences: string[];
|
||||
isProcessing: boolean;
|
||||
setIsProcessing: (value: boolean) => void;
|
||||
setIsPlaying: (value: boolean) => void;
|
||||
speed: number;
|
||||
setSpeed: (speed: number) => void;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
|
|
@ -47,6 +49,13 @@ interface TTSContextType {
|
|||
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;
|
||||
incrementPage: (num?: number) => void;
|
||||
skipToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||
|
|
@ -62,98 +71,111 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
const [currentText, setCurrentText] = useState('');
|
||||
const [sentences, setSentences] = useState<string[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [audioContext, setAudioContext] = useState<AudioContextType>(null);
|
||||
const currentRequestRef = useRef<AbortController | null>(null);
|
||||
const [audioContext, setAudioContext] = useState<AudioContextType>();
|
||||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||||
const [audioQueue] = useState<AudioBuffer[]>([]);
|
||||
const [currentAudioIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const skipTriggeredRef = useRef(false);
|
||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPausingRef = useRef(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [voice, setVoice] = useState('alloy');
|
||||
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
|
||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||
|
||||
// Move these function declarations up before they are used
|
||||
const setText = useCallback((text: string) => {
|
||||
setCurrentText(text);
|
||||
console.log('Setting page text:', text);
|
||||
const newSentences = splitIntoSentences(text);
|
||||
setSentences(newSentences);
|
||||
|
||||
// Clear audio cache
|
||||
//audioCacheRef.current.clear();
|
||||
|
||||
setNextPageLoading(false);
|
||||
}, []);
|
||||
|
||||
const abortAudio = useCallback(() => {
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
}, [activeHowl]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
setIsPlaying((prev) => {
|
||||
if (!prev) {
|
||||
isPausingRef.current = false;
|
||||
return true;
|
||||
} else {
|
||||
if (activeHowl) {
|
||||
isPausingRef.current = true;
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
abortAudio();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [activeHowl]);
|
||||
}, [abortAudio]);
|
||||
|
||||
const skipToPage = useCallback((page: number) => {
|
||||
abortAudio();
|
||||
setIsPlaying(false);
|
||||
setNextPageLoading(true);
|
||||
setCurrentIndex(0);
|
||||
setCurrDocPage(page);
|
||||
}, [abortAudio]);
|
||||
|
||||
const incrementPage = useCallback((num = 1) => {
|
||||
setNextPageLoading(true);
|
||||
setCurrDocPage(currDocPage + num);
|
||||
}, [currDocPage]);
|
||||
|
||||
const advance = useCallback(async (backwards = false) => {
|
||||
setCurrentIndex((prev) => {
|
||||
const nextIndex = prev + (backwards ? -1 : 1);
|
||||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
||||
console.log('Advancing to next sentence:', sentences[nextIndex]);
|
||||
return nextIndex;
|
||||
} else if (nextIndex >= sentences.length && currDocPage < currDocPages!) {
|
||||
console.log('Advancing to next page:', currDocPage + 1);
|
||||
|
||||
incrementPage();
|
||||
|
||||
return 0;
|
||||
} else if (nextIndex < 0 && currDocPage > 1) {
|
||||
console.log('Advancing to previous page:', currDocPage - 1);
|
||||
|
||||
incrementPage(-1);
|
||||
|
||||
return 0;
|
||||
} else if (nextIndex >= sentences.length && currDocPage >= currDocPages!) {
|
||||
console.log('Reached end of document');
|
||||
setIsPlaying(false);
|
||||
return prev;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [sentences, currDocPage, currDocPages, incrementPage]);
|
||||
|
||||
|
||||
const skipForward = useCallback(() => {
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
|
||||
skipTriggeredRef.current = true;
|
||||
setIsProcessing(true);
|
||||
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
abortAudio();
|
||||
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
advance();
|
||||
|
||||
setCurrentIndex((prev) => {
|
||||
const nextIndex = Math.min(prev + 1, sentences.length - 1);
|
||||
console.log('Skipping forward to:', sentences[nextIndex]);
|
||||
return nextIndex;
|
||||
});
|
||||
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
skipTriggeredRef.current = false;
|
||||
setIsProcessing(false);
|
||||
}, 100);
|
||||
}, [sentences, activeHowl]);
|
||||
setIsProcessing(false);
|
||||
}, [abortAudio, advance]);
|
||||
|
||||
const skipBackward = useCallback(() => {
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
|
||||
skipTriggeredRef.current = true;
|
||||
setIsProcessing(true);
|
||||
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
abortAudio();
|
||||
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
advance(true); // Pass true to go backwards
|
||||
|
||||
setCurrentIndex((prev) => {
|
||||
const nextIndex = Math.max(prev - 1, 0);
|
||||
console.log('Skipping backward to:', sentences[nextIndex]);
|
||||
return nextIndex;
|
||||
});
|
||||
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
skipTriggeredRef.current = false;
|
||||
setIsProcessing(false);
|
||||
}, 100);
|
||||
}, [sentences, activeHowl]);
|
||||
setIsProcessing(false);
|
||||
}, [abortAudio, advance]);
|
||||
|
||||
// Initialize OpenAI instance when config loads
|
||||
useEffect(() => {
|
||||
|
|
@ -206,6 +228,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioContext) {
|
||||
audioContext.close().catch((error) => {
|
||||
console.error('Error closing AudioContext:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [audioContext]);
|
||||
|
||||
// Now the MediaSession effect can use these functions
|
||||
|
|
@ -230,110 +260,60 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}, [togglePlay, skipForward, skipBackward]);
|
||||
|
||||
// Text preprocessing function to clean and normalize text
|
||||
const preprocessSentenceForAudio = (text: string): string => {
|
||||
return text
|
||||
// Replace URLs with descriptive text including domain
|
||||
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
|
||||
// Remove special characters except basic punctuation
|
||||
//.replace(/[^\w\s.,!?;:'"()-]/g, ' ')
|
||||
// Fix hyphenated words at line breaks (word- word -> wordword)
|
||||
.replace(/(\w+)-\s+(\w+)/g, '$1$2')
|
||||
// Replace multiple spaces with single space
|
||||
.replace(/\s+/g, ' ')
|
||||
// Trim whitespace
|
||||
.trim();
|
||||
};
|
||||
|
||||
const splitIntoSentences = (text: string): string[] => {
|
||||
// Preprocess the text before splitting into sentences
|
||||
const cleanedText = preprocessSentenceForAudio(text);
|
||||
const doc = nlp(cleanedText);
|
||||
return doc.sentences().out('array') as string[];
|
||||
};
|
||||
|
||||
const processNextSentence = useCallback(async () => {
|
||||
if (!isPlaying || currentIndex >= sentences.length - 1 || skipTriggeredRef.current) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
//new function to return audio buffer with caching
|
||||
const getAudio = useCallback(async (sentence: string): Promise<AudioBuffer | undefined> => {
|
||||
// Check if the audio is already cached
|
||||
const cachedAudio = audioCacheRef.current.get(sentence);
|
||||
if (cachedAudio) {
|
||||
console.log('Using cached audio for sentence:', sentence.substring(0, 20));
|
||||
return cachedAudio;
|
||||
}
|
||||
|
||||
setCurrentIndex((prev) => {
|
||||
const nextIndex = prev + 1;
|
||||
if (nextIndex < sentences.length) {
|
||||
console.log('Auto-advancing to next sentence:', sentences[nextIndex]);
|
||||
return nextIndex;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [isPlaying, currentIndex, sentences]);
|
||||
// If not cached, fetch the audio from OpenAI API
|
||||
if (openaiRef.current) {
|
||||
console.log('Requesting audio for sentence:', sentence);
|
||||
|
||||
const processAndPlaySentence = async (sentence: string) => {
|
||||
if (!audioContext || isProcessing || !openaiRef.current) return;
|
||||
const response = await openaiRef.current.audio.speech.create({
|
||||
model: 'tts-1',
|
||||
voice: voice as "alloy",
|
||||
input: sentence,
|
||||
speed: speed,
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext!.decodeAudioData(arrayBuffer);
|
||||
|
||||
// Cache the audio buffer
|
||||
audioCacheRef.current.set(sentence, audioBuffer);
|
||||
|
||||
return audioBuffer;
|
||||
}
|
||||
}, [audioContext, voice, speed]);
|
||||
|
||||
const processSentence = useCallback(async (sentence: string, preload = false): Promise<string> => {
|
||||
if (isProcessing && !preload) throw new Error('Audio is already being processed');
|
||||
if (!audioContext || !openaiRef.current) throw new Error('Audio context not initialized');
|
||||
|
||||
// Only set processing state if not preloading
|
||||
if (!preload) setIsProcessing(true);
|
||||
|
||||
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
||||
const audioBuffer = await getAudio(cleanedSentence);
|
||||
|
||||
return audioBufferToURL(audioBuffer!);
|
||||
}, [isProcessing, audioContext, getAudio]);
|
||||
|
||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
||||
try {
|
||||
// Only set processing if we need to fetch from API
|
||||
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
||||
if (!audioCacheRef.current.has(cleanedSentence)) {
|
||||
setIsProcessing(true);
|
||||
const audioUrl = await processSentence(sentence);
|
||||
if (!audioUrl) {
|
||||
throw new Error('No audio URL generated');
|
||||
}
|
||||
|
||||
// Cancel any existing request
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this request
|
||||
currentRequestRef.current = new AbortController();
|
||||
|
||||
// Stop any currently playing audio
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
|
||||
let audioBuffer = audioCacheRef.current.get(cleanedSentence);
|
||||
|
||||
if (!audioBuffer) {
|
||||
console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...');
|
||||
const startTime = Date.now();
|
||||
const response = await openaiRef.current.audio.speech.create({
|
||||
model: 'tts-1',
|
||||
voice: voice as "alloy",
|
||||
input: cleanedSentence,
|
||||
speed: speed,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(` TTS processing completed in ${duration}ms`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
// Store in cache
|
||||
audioCacheRef.current.set(cleanedSentence, audioBuffer);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
// If the request was aborted or component unmounted, do not proceed
|
||||
if (!currentRequestRef.current) return;
|
||||
|
||||
// Convert AudioBuffer to URL for Howler
|
||||
const audioUrl = audioBufferToURL(audioBuffer!);
|
||||
|
||||
|
||||
const howl = new Howl({
|
||||
src: [audioUrl],
|
||||
format: ['wav'],
|
||||
html5: true,
|
||||
onend: () => {
|
||||
setActiveHowl(null);
|
||||
// Cleanup the URL when audio ends
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) {
|
||||
processNextSentence();
|
||||
}
|
||||
isPausingRef.current = false;
|
||||
},
|
||||
onplay: () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
|
|
@ -344,281 +324,110 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
},
|
||||
onend: () => {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
setActiveHowl(null);
|
||||
if (isPlaying) {
|
||||
advance();
|
||||
}
|
||||
},
|
||||
onloaderror: (id, error) => {
|
||||
console.error('Error loading audio:', error);
|
||||
setIsProcessing(false);
|
||||
setActiveHowl(null);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
// Don't auto-advance on load error
|
||||
setIsPlaying(false);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
setActiveHowl(howl);
|
||||
howl.play();
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('Request was cancelled');
|
||||
} else {
|
||||
console.error('Error processing TTS:', error);
|
||||
}
|
||||
setIsProcessing(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error playing TTS:', error);
|
||||
setActiveHowl(null);
|
||||
setIsProcessing(false);
|
||||
} finally {
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add utility function to convert AudioBuffer to URL
|
||||
const audioBufferToURL = (audioBuffer: AudioBuffer): string => {
|
||||
// Get WAV file bytes
|
||||
const wavBytes = getWavBytes(audioBuffer.getChannelData(0), {
|
||||
isFloat: true, // floating point or 16-bit integer
|
||||
numChannels: 1, // number of channels
|
||||
sampleRate: audioBuffer.sampleRate, // audio sample rate
|
||||
});
|
||||
|
||||
// Create blob and URL
|
||||
const blob = new Blob([wavBytes], { type: 'audio/wav' });
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
// Add helper function for WAV conversion
|
||||
const getWavBytes = (samples: Float32Array, opts: {
|
||||
isFloat?: boolean,
|
||||
numChannels?: number,
|
||||
sampleRate?: number,
|
||||
}) => {
|
||||
const {
|
||||
isFloat = true,
|
||||
numChannels = 1,
|
||||
sampleRate = 44100,
|
||||
} = opts;
|
||||
|
||||
const bytesPerSample = isFloat ? 4 : 2;
|
||||
const numSamples = samples.length;
|
||||
|
||||
// WAV header size is 44 bytes
|
||||
const buffer = new ArrayBuffer(44 + numSamples * bytesPerSample);
|
||||
const dv = new DataView(buffer);
|
||||
|
||||
let pos = 0;
|
||||
|
||||
// Write WAV header
|
||||
writeString(dv, pos, 'RIFF'); pos += 4;
|
||||
dv.setUint32(pos, 36 + numSamples * bytesPerSample, true); pos += 4;
|
||||
writeString(dv, pos, 'WAVE'); pos += 4;
|
||||
writeString(dv, pos, 'fmt '); pos += 4;
|
||||
dv.setUint32(pos, 16, true); pos += 4;
|
||||
dv.setUint16(pos, isFloat ? 3 : 1, true); pos += 2;
|
||||
dv.setUint16(pos, numChannels, true); pos += 2;
|
||||
dv.setUint32(pos, sampleRate, true); pos += 4;
|
||||
dv.setUint32(pos, sampleRate * numChannels * bytesPerSample, true); pos += 4;
|
||||
dv.setUint16(pos, numChannels * bytesPerSample, true); pos += 2;
|
||||
dv.setUint16(pos, bytesPerSample * 8, true); pos += 2;
|
||||
writeString(dv, pos, 'data'); pos += 4;
|
||||
dv.setUint32(pos, numSamples * bytesPerSample, true); pos += 4;
|
||||
|
||||
if (isFloat) {
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
dv.setFloat32(pos, samples[i], true);
|
||||
pos += bytesPerSample;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
dv.setInt16(pos, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
pos += bytesPerSample;
|
||||
//setIsPlaying(false); // Stop playback on error
|
||||
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('Unable to decode audio data') ||
|
||||
error.message.includes('EncodingError'))) {
|
||||
console.log('Skipping problematic sentence:', sentence);
|
||||
advance(); // Skip problematic sentence
|
||||
}
|
||||
}
|
||||
}, [isPlaying, processSentence, advance]);
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const writeString = (view: DataView, offset: number, string: string): void => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
const setText = useCallback((text: string) => {
|
||||
setCurrentText(text);
|
||||
const newSentences = splitIntoSentences(text);
|
||||
setSentences(newSentences);
|
||||
setCurrentIndex(0);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Clear audio cache
|
||||
audioCacheRef.current.clear();
|
||||
|
||||
// Preload the first sentence immediately
|
||||
if (newSentences.length > 0) {
|
||||
preloadSentence(newSentences[0]).then(() => {
|
||||
// Preload the second sentence after a small delay
|
||||
if (newSentences[1]) {
|
||||
setTimeout(() => preloadSentence(newSentences[1]), 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Preload adjacent sentences when currentIndex changes
|
||||
useEffect(() => {
|
||||
/*
|
||||
* Preloads the next sentence in the queue to improve playback performance.
|
||||
* Only preloads the next sentence to reduce API load.
|
||||
*
|
||||
* Dependencies:
|
||||
* - currentIndex: Re-runs when the currentIndex changes
|
||||
* - sentences: Re-runs when the sentences array changes
|
||||
*/
|
||||
const preloadAdjacentSentences = async () => {
|
||||
try {
|
||||
// Only preload next sentence to reduce API load
|
||||
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
|
||||
await preloadSentence(sentences[currentIndex + 1]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preloading adjacent sentences:', error);
|
||||
}
|
||||
};
|
||||
preloadAdjacentSentences();
|
||||
}, [currentIndex, sentences]);
|
||||
|
||||
const isMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
* Plays the current sentence when the component is mounted or the currentIndex changes.
|
||||
* Handles audio playback and auto-advances to the next sentence when finished.
|
||||
*
|
||||
* Dependencies:
|
||||
* - isPlaying: Re-runs when the isPlaying state changes
|
||||
* - currentIndex: Re-runs when the currentIndex changes
|
||||
* - sentences: Re-runs when the sentences array changes
|
||||
* - isProcessing: Re-runs when the isProcessing state changes
|
||||
*/
|
||||
// Skip the first mount in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let isEffectActive = true;
|
||||
|
||||
const playAudio = async () => {
|
||||
if (isPlaying && sentences[currentIndex] && !isProcessing && isEffectActive) {
|
||||
await processAndPlaySentence(sentences[currentIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
playAudio();
|
||||
|
||||
return () => {
|
||||
isEffectActive = false;
|
||||
// Clean up any playing audio when the effect is cleaned up
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPlaying, currentIndex, sentences, isProcessing]);
|
||||
|
||||
const preloadSentence = async (sentence: string) => {
|
||||
if (!audioContext || !openaiRef.current) return;
|
||||
if (audioCacheRef.current.has(sentence)) return; // Already cached
|
||||
|
||||
const preloadNextAudio = useCallback(() => {
|
||||
try {
|
||||
console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...');
|
||||
const startTime = Date.now();
|
||||
const response = await openaiRef.current.audio.speech.create({
|
||||
model: 'tts-1',
|
||||
voice: voice as "alloy",
|
||||
input: sentence,
|
||||
speed: speed,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(` Preload TTS completed in ${duration}ms`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
// Store in cache
|
||||
audioCacheRef.current.set(sentence, audioBuffer);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('Request was cancelled');
|
||||
} else {
|
||||
console.error('Error preloading TTS:', error);
|
||||
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
||||
processSentence(sentences[currentIndex + 1], true); // True indicates preloading
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preloading next sentence:', error);
|
||||
}
|
||||
};
|
||||
}, [currentIndex, sentences, audioCacheRef, processSentence]);
|
||||
|
||||
const playAudio = useCallback(async () => {
|
||||
await playSentenceWithHowl(sentences[currentIndex]);
|
||||
}, [sentences, currentIndex, playSentenceWithHowl]);
|
||||
|
||||
// main driver useEffect
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return; // Don't proceed if stopped
|
||||
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
|
||||
|
||||
// Play the current sentence and preload the next one if available
|
||||
playAudio();
|
||||
if (sentences[currentIndex + 1]) {
|
||||
preloadNextAudio();
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortAudio();
|
||||
};
|
||||
}, [
|
||||
isPlaying,
|
||||
isProcessing,
|
||||
currentIndex,
|
||||
sentences,
|
||||
activeHowl,
|
||||
nextPageLoading,
|
||||
playAudio,
|
||||
preloadNextAudio,
|
||||
abortAudio
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Cancel any ongoing request
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
|
||||
// Stop current audio
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
abortAudio();
|
||||
|
||||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
setCurrentText('');
|
||||
setIsProcessing(false);
|
||||
}, [activeHowl]);
|
||||
}, [abortAudio]);
|
||||
|
||||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||||
// Cancel any ongoing request
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
|
||||
// Stop current audio
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
|
||||
// Set skip flag to prevent immediate auto-advance
|
||||
skipTriggeredRef.current = true;
|
||||
|
||||
// Set new index and start playing
|
||||
setCurrentIndex(index);
|
||||
setIsPlaying(true);
|
||||
|
||||
// Reset skip flag after a short delay to allow future auto-advance
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
skipTriggeredRef.current = false;
|
||||
}, 100);
|
||||
}, [activeHowl]);
|
||||
abortAudio();
|
||||
|
||||
// Set the states in the next tick to ensure clean state
|
||||
setTimeout(() => {
|
||||
setCurrentIndex(index);
|
||||
setIsPlaying(true);
|
||||
}, 50);
|
||||
}, [abortAudio]);
|
||||
|
||||
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
||||
// Cancel any ongoing request
|
||||
if (currentRequestRef.current) {
|
||||
currentRequestRef.current.abort();
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
|
||||
// Stop current audio
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
abortAudio();
|
||||
|
||||
setCurrentIndex(index);
|
||||
skipTriggeredRef.current = false;
|
||||
}, [activeHowl]);
|
||||
}, [abortAudio]);
|
||||
|
||||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
setSpeed(newSpeed);
|
||||
|
|
@ -661,12 +470,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
setText,
|
||||
currentSentence: sentences[currentIndex] || '',
|
||||
audioQueue,
|
||||
currentAudioIndex,
|
||||
stop,
|
||||
setCurrentIndex: setCurrentIndexWithoutPlay,
|
||||
stopAndPlayFromIndex,
|
||||
sentences,
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
setIsPlaying,
|
||||
speed,
|
||||
setSpeed,
|
||||
setSpeedAndRestart,
|
||||
|
|
@ -674,6 +484,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|||
setVoice,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
currentIndex,
|
||||
currDocPage,
|
||||
currDocPages,
|
||||
setCurrDocPages,
|
||||
incrementPage,
|
||||
skipToPage,
|
||||
};
|
||||
|
||||
if (configIsLoading) {
|
||||
|
|
|
|||
71
src/services/audio.ts
Normal file
71
src/services/audio.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Add utility function to convert AudioBuffer to URL
|
||||
export const audioBufferToURL = (audioBuffer: AudioBuffer): string => {
|
||||
// Get WAV file bytes
|
||||
const wavBytes = getWavBytes(audioBuffer.getChannelData(0), {
|
||||
isFloat: true, // floating point or 16-bit integer
|
||||
numChannels: 1, // number of channels
|
||||
sampleRate: audioBuffer.sampleRate, // audio sample rate
|
||||
});
|
||||
|
||||
// Create blob and URL
|
||||
const blob = new Blob([wavBytes], { type: 'audio/wav' });
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
// Add helper function for WAV conversion
|
||||
export const getWavBytes = (samples: Float32Array, opts: {
|
||||
isFloat?: boolean,
|
||||
numChannels?: number,
|
||||
sampleRate?: number,
|
||||
}) => {
|
||||
const {
|
||||
isFloat = true,
|
||||
numChannels = 1,
|
||||
sampleRate = 44100,
|
||||
} = opts;
|
||||
|
||||
const bytesPerSample = isFloat ? 4 : 2;
|
||||
const numSamples = samples.length;
|
||||
|
||||
// WAV header size is 44 bytes
|
||||
const buffer = new ArrayBuffer(44 + numSamples * bytesPerSample);
|
||||
const dv = new DataView(buffer);
|
||||
|
||||
let pos = 0;
|
||||
|
||||
// Write WAV header
|
||||
writeString(dv, pos, 'RIFF'); pos += 4;
|
||||
dv.setUint32(pos, 36 + numSamples * bytesPerSample, true); pos += 4;
|
||||
writeString(dv, pos, 'WAVE'); pos += 4;
|
||||
writeString(dv, pos, 'fmt '); pos += 4;
|
||||
dv.setUint32(pos, 16, true); pos += 4;
|
||||
dv.setUint16(pos, isFloat ? 3 : 1, true); pos += 2;
|
||||
dv.setUint16(pos, numChannels, true); pos += 2;
|
||||
dv.setUint32(pos, sampleRate, true); pos += 4;
|
||||
dv.setUint32(pos, sampleRate * numChannels * bytesPerSample, true); pos += 4;
|
||||
dv.setUint16(pos, numChannels * bytesPerSample, true); pos += 2;
|
||||
dv.setUint16(pos, bytesPerSample * 8, true); pos += 2;
|
||||
writeString(dv, pos, 'data'); pos += 4;
|
||||
dv.setUint32(pos, numSamples * bytesPerSample, true); pos += 4;
|
||||
|
||||
if (isFloat) {
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
dv.setFloat32(pos, samples[i], true);
|
||||
pos += bytesPerSample;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
dv.setInt16(pos, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
pos += bytesPerSample;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
export const writeString = (view: DataView, offset: number, string: string): void => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
23
src/services/nlp.ts
Normal file
23
src/services/nlp.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import nlp from 'compromise';
|
||||
|
||||
// Text preprocessing function to clean and normalize text
|
||||
export const preprocessSentenceForAudio = (text: string): string => {
|
||||
return text
|
||||
// Replace URLs with descriptive text including domain
|
||||
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
|
||||
// Remove special characters except basic punctuation
|
||||
//.replace(/[^\w\s.,!?;:'"()-]/g, ' ')
|
||||
// Fix hyphenated words at line breaks (word- word -> wordword)
|
||||
.replace(/(\w+)-\s+(\w+)/g, '$1$2')
|
||||
// Replace multiple spaces with single space
|
||||
.replace(/\s+/g, ' ')
|
||||
// Trim whitespace
|
||||
.trim();
|
||||
};
|
||||
|
||||
export const splitIntoSentences = (text: string): string[] => {
|
||||
// Preprocess the text before splitting into sentences
|
||||
const cleanedText = preprocessSentenceForAudio(text);
|
||||
const doc = nlp(cleanedText);
|
||||
return doc.sentences().out('array') as string[];
|
||||
};
|
||||
Loading…
Reference in a new issue