refactor
This commit is contained in:
parent
a166915bc4
commit
1c58891a59
12 changed files with 467 additions and 507 deletions
|
|
@ -6,7 +6,6 @@ import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
||||||
import TTSPlayer from '@/components/TTSPlayer';
|
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
|
||||||
// Dynamic import for client-side rendering only
|
// Dynamic import for client-side rendering only
|
||||||
|
|
@ -73,7 +72,6 @@ export default function PDFViewerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TTSPlayer />
|
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<div className="p-2 pb-2 border-b border-offbase">
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import { pdfjs } from 'react-pdf';
|
import { pdfjs } from 'react-pdf';
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
||||||
interface PDFViewerProps {
|
interface PDFViewerProps {
|
||||||
pdfData: Blob | undefined;
|
pdfData: Blob | undefined;
|
||||||
|
|
@ -19,7 +20,7 @@ interface PDFViewerProps {
|
||||||
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing, isPlaying, currentIndex, sentences } = useTTS();
|
const { setText, currentSentence, stopAndPlayFromIndex, isProcessing, isPlaying, currentIndex, sentences, stop } = useTTS();
|
||||||
const [pdfText, setPdfText] = useState('');
|
const [pdfText, setPdfText] = useState('');
|
||||||
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
const [pdfDataUrl, setPdfDataUrl] = useState<string>();
|
||||||
const [loadingError, setLoadingError] = useState<string>();
|
const [loadingError, setLoadingError] = useState<string>();
|
||||||
|
|
@ -179,6 +180,14 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
|
|
||||||
const handlePageChange = async (pageNumber: number) => {
|
const handlePageChange = async (pageNumber: number) => {
|
||||||
if (pageNumber < 1 || pageNumber > (numPages || 1)) return;
|
if (pageNumber < 1 || pageNumber > (numPages || 1)) return;
|
||||||
|
|
||||||
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
// Stop current playback and reset states
|
||||||
|
if (isPlaying) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentPage(pageNumber);
|
setCurrentPage(pageNumber);
|
||||||
|
|
||||||
// Extract text from the new page
|
// Extract text from the new page
|
||||||
|
|
@ -240,6 +249,13 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
|
|
||||||
setPageText(fullText.trim());
|
setPageText(fullText.trim());
|
||||||
setText(fullText.trim()); // Update TTS with current page text
|
setText(fullText.trim()); // Update TTS with current page text
|
||||||
|
|
||||||
|
// // Wait for text processing before resuming playback
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// if (wasPlaying) {
|
||||||
|
// stopAndPlayFromIndex(0);
|
||||||
|
// }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting page text:', error);
|
console.error('Error extracting page text:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -248,17 +264,16 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
|
|
||||||
// Auto-advance to next page when TTS reaches the end
|
// Auto-advance to next page when TTS reaches the end
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying && currentIndex >= sentences.length - 1) {
|
if (!isPlaying && currentIndex >= sentences.length - 1 && currentPage < (numPages || 1)) {
|
||||||
handlePageChange(currentPage + 1);
|
const timer = setTimeout(() => {
|
||||||
|
handlePageChange(currentPage + 1);
|
||||||
|
}, 500); // Longer delay to ensure states are settled
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isPlaying, currentIndex, sentences.length, currentPage]);
|
}, [isPlaying, currentIndex, sentences.length, currentPage, numPages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
||||||
ref={containerRef}
|
|
||||||
className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6"
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
{loadingError ? (
|
{loadingError ? (
|
||||||
<div className="text-red-500 mb-4">{loadingError}</div>
|
<div className="text-red-500 mb-4">{loadingError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -273,27 +288,6 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
className="flex flex-col items-center m-0"
|
className="flex flex-col items-center m-0"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-center gap-4 mb-4">
|
|
||||||
<button
|
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
className="bg-offbase px-4 py-2 rounded-full disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<div className="bg-offbase px-2 py-0.5 rounded-full">
|
|
||||||
<p className="text-xs">
|
|
||||||
{currentPage} / {numPages}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage >= (numPages || 1)}
|
|
||||||
className="bg-offbase px-4 py-2 rounded-full disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Page
|
<Page
|
||||||
pageNumber={currentPage}
|
pageNumber={currentPage}
|
||||||
|
|
@ -305,6 +299,11 @@ export function PDFViewer({ pdfData, zoomLevel }: PDFViewerProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Document>
|
</Document>
|
||||||
|
<TTSPlayer
|
||||||
|
currentPage={currentPage}
|
||||||
|
numPages={numPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
</div>
|
</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, onPageChange }: {
|
||||||
|
currentPage: number;
|
||||||
|
numPages: number | undefined;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{/* Page back */}
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(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">
|
||||||
|
{currentPage} / {numPages || 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page forward */}
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(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, onPageChange }: {
|
||||||
|
currentPage: number;
|
||||||
|
numPages: number | undefined;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isPlaying,
|
||||||
|
togglePlay,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
isProcessing,
|
||||||
|
speed,
|
||||||
|
setSpeedAndRestart,
|
||||||
|
voice,
|
||||||
|
setVoiceAndRestart,
|
||||||
|
availableVoices,
|
||||||
|
} = 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} onPageChange={onPageChange} />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -324,12 +324,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
// Handle text click events in the PDF viewer
|
// Handle text click events in the PDF viewer
|
||||||
const handleTextClick = useCallback((
|
const handleTextClick = useCallback((
|
||||||
event: MouseEvent,
|
event: MouseEvent,
|
||||||
pdfText: string,
|
pageText: 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
|
||||||
) => {
|
) => {
|
||||||
if (isProcessing) return; // Don't process clicks while TTS is processing
|
if (isProcessing) return;
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
||||||
|
|
@ -361,7 +361,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
if (bestMatch.rating >= similarityThreshold) {
|
if (bestMatch.rating >= similarityThreshold) {
|
||||||
const matchText = bestMatch.text;
|
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 };
|
let bestSentenceMatch = { sentence: '', rating: 0 };
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
for (const sentence of sentences) {
|
||||||
|
|
@ -375,7 +376,7 @@ 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(pdfText, bestSentenceMatch.sentence, containerRef);
|
highlightPattern(pageText, bestSentenceMatch.sentence, containerRef);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,15 @@ import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import nlp from 'compromise';
|
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
||||||
import { useConfig } from './ConfigContext';
|
|
||||||
import { Howl } from 'howler';
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
webkitAudioContext: typeof AudioContext;
|
webkitAudioContext: typeof AudioContext;
|
||||||
|
|
@ -34,12 +36,12 @@ interface TTSContextType {
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
currentSentence: string;
|
currentSentence: string;
|
||||||
audioQueue: AudioBuffer[];
|
audioQueue: AudioBuffer[];
|
||||||
currentAudioIndex: number;
|
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
setCurrentIndex: (index: number) => void;
|
|
||||||
stopAndPlayFromIndex: (index: number) => void;
|
stopAndPlayFromIndex: (index: number) => void;
|
||||||
sentences: string[];
|
sentences: string[];
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
|
setIsProcessing: (value: boolean) => void;
|
||||||
|
setIsPlaying: (value: boolean) => void;
|
||||||
speed: number;
|
speed: number;
|
||||||
setSpeed: (speed: number) => void;
|
setSpeed: (speed: number) => void;
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
|
|
@ -47,6 +49,8 @@ interface TTSContextType {
|
||||||
setVoice: (voice: string) => void;
|
setVoice: (voice: string) => void;
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
|
currentIndex: number;
|
||||||
|
setCurrentIndex: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
@ -66,10 +70,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
const currentRequestRef = useRef<AbortController | null>(null);
|
const currentRequestRef = useRef<AbortController | null>(null);
|
||||||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||||||
const [audioQueue] = useState<AudioBuffer[]>([]);
|
const [audioQueue] = useState<AudioBuffer[]>([]);
|
||||||
const [currentAudioIndex] = useState(0);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const skipTriggeredRef = useRef(false);
|
|
||||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const isPausingRef = useRef(false);
|
const isPausingRef = useRef(false);
|
||||||
const [speed, setSpeed] = useState(1);
|
const [speed, setSpeed] = useState(1);
|
||||||
const [voice, setVoice] = useState('alloy');
|
const [voice, setVoice] = useState('alloy');
|
||||||
|
|
@ -78,31 +79,32 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Audio cache using LRUCache with a maximum size of 50 entries
|
// Audio cache using LRUCache with a maximum size of 50 entries
|
||||||
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
||||||
|
|
||||||
// Move these function declarations up before they are used
|
const setText = useCallback((text: string) => {
|
||||||
|
setCurrentText(text);
|
||||||
|
const newSentences = splitIntoSentences(text);
|
||||||
|
setSentences(newSentences);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// Clear audio cache
|
||||||
|
audioCacheRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
setIsPlaying((prev) => {
|
setIsPlaying((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
isPausingRef.current = false;
|
isPausingRef.current = false;
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
if (activeHowl) {
|
isPausingRef.current = true;
|
||||||
isPausingRef.current = true;
|
abortAudio();
|
||||||
activeHowl.stop();
|
|
||||||
setActiveHowl(null);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
|
|
||||||
const skipForward = useCallback(() => {
|
const abortAudio = useCallback(() => {
|
||||||
if (skipTimeoutRef.current) {
|
|
||||||
clearTimeout(skipTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
skipTriggeredRef.current = true;
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
if (currentRequestRef.current) {
|
if (currentRequestRef.current) {
|
||||||
currentRequestRef.current.abort();
|
currentRequestRef.current.abort();
|
||||||
currentRequestRef.current = null;
|
currentRequestRef.current = null;
|
||||||
|
|
@ -112,6 +114,12 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
activeHowl.stop();
|
activeHowl.stop();
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
}
|
}
|
||||||
|
}, [currentRequestRef, activeHowl]);
|
||||||
|
|
||||||
|
const skipForward = useCallback(() => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
abortAudio();
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
const nextIndex = Math.min(prev + 1, sentences.length - 1);
|
const nextIndex = Math.min(prev + 1, sentences.length - 1);
|
||||||
|
|
@ -119,40 +127,22 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
return nextIndex;
|
return nextIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
skipTimeoutRef.current = setTimeout(() => {
|
setIsProcessing(false);
|
||||||
skipTriggeredRef.current = false;
|
|
||||||
setIsProcessing(false);
|
|
||||||
}, 100);
|
|
||||||
}, [sentences, activeHowl]);
|
}, [sentences, activeHowl]);
|
||||||
|
|
||||||
const skipBackward = useCallback(() => {
|
const skipBackward = useCallback(() => {
|
||||||
if (skipTimeoutRef.current) {
|
|
||||||
clearTimeout(skipTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
skipTriggeredRef.current = true;
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
if (currentRequestRef.current) {
|
abortAudio();
|
||||||
currentRequestRef.current.abort();
|
|
||||||
currentRequestRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeHowl) {
|
|
||||||
activeHowl.stop();
|
|
||||||
setActiveHowl(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
const nextIndex = Math.max(prev - 1, 0);
|
const nextIndex = Math.max(prev - 1, 0);
|
||||||
console.log('Skipping backward to:', sentences[nextIndex]);
|
console.log('Skipping backward to:', sentences[nextIndex]);
|
||||||
return nextIndex;
|
return nextIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
skipTimeoutRef.current = setTimeout(() => {
|
setIsProcessing(false);
|
||||||
skipTriggeredRef.current = false;
|
|
||||||
setIsProcessing(false);
|
|
||||||
}, 100);
|
|
||||||
}, [sentences, activeHowl]);
|
}, [sentences, activeHowl]);
|
||||||
|
|
||||||
// Initialize OpenAI instance when config loads
|
// Initialize OpenAI instance when config loads
|
||||||
|
|
@ -230,31 +220,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [togglePlay, skipForward, skipBackward]);
|
}, [togglePlay, skipForward, skipBackward]);
|
||||||
|
|
||||||
// Text preprocessing function to clean and normalize text
|
const advance = useCallback(async () => {
|
||||||
const preprocessSentenceForAudio = (text: string): string => {
|
if (currentIndex >= sentences.length - 1) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,74 +233,67 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [isPlaying, currentIndex, sentences]);
|
}, [currentIndex, sentences]);
|
||||||
|
|
||||||
const processAndPlaySentence = async (sentence: string) => {
|
//new function to return audio buffer with caching
|
||||||
if (!audioContext || isProcessing || !openaiRef.current) return;
|
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);
|
||||||
|
return cachedAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not cached, fetch the audio from OpenAI API
|
||||||
|
if (openaiRef.current) {
|
||||||
|
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 = async (sentence: string, preload: boolean = false): Promise<string> => {
|
||||||
|
if (!audioContext || !openaiRef.current) throw new Error('Audio context not initialized');
|
||||||
|
if (!preload) setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only set processing if we need to fetch from API
|
|
||||||
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
const cleanedSentence = preprocessSentenceForAudio(sentence);
|
||||||
if (!audioCacheRef.current.has(cleanedSentence)) {
|
|
||||||
setIsProcessing(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any existing request
|
|
||||||
if (currentRequestRef.current) {
|
|
||||||
currentRequestRef.current.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new abort controller for this request
|
|
||||||
currentRequestRef.current = new AbortController();
|
currentRequestRef.current = new AbortController();
|
||||||
|
|
||||||
// Stop any currently playing audio
|
const audioBuffer = await getAudio(cleanedSentence);
|
||||||
if (activeHowl) {
|
if (!currentRequestRef.current) throw new Error('Request cancelled');
|
||||||
activeHowl.stop();
|
|
||||||
setActiveHowl(null);
|
return audioBufferToURL(audioBuffer!);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.log('Request was cancelled');
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let audioBuffer = audioCacheRef.current.get(cleanedSentence);
|
const playSentence = async (sentence: string) => {
|
||||||
|
try {
|
||||||
if (!audioBuffer) {
|
const audioUrl = await processSentence(sentence);
|
||||||
console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...');
|
setIsProcessing(false);
|
||||||
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({
|
const howl = new Howl({
|
||||||
src: [audioUrl],
|
src: [audioUrl],
|
||||||
format: ['wav'],
|
format: ['wav'],
|
||||||
html5: true,
|
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: () => {
|
onplay: () => {
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.playbackState = 'playing';
|
navigator.mediaSession.playbackState = 'playing';
|
||||||
|
|
@ -344,117 +304,39 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
navigator.mediaSession.playbackState = 'paused';
|
navigator.mediaSession.playbackState = 'paused';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onend: () => {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
setActiveHowl(null);
|
||||||
|
if (isPlaying && !isPausingRef.current) {
|
||||||
|
advance();
|
||||||
|
}
|
||||||
|
isPausingRef.current = false;
|
||||||
|
},
|
||||||
|
onloaderror: (id, error) => {
|
||||||
|
console.error('Error loading audio:', error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
setActiveHowl(null);
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
advance();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setActiveHowl(howl);
|
setActiveHowl(howl);
|
||||||
howl.play();
|
howl.play();
|
||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
console.error('Error playing TTS:', error);
|
||||||
console.log('Request was cancelled');
|
|
||||||
} else {
|
|
||||||
console.error('Error processing TTS:', error);
|
|
||||||
}
|
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
} finally {
|
if (error instanceof Error &&
|
||||||
currentRequestRef.current = null;
|
(error.message.includes('Unable to decode audio data') ||
|
||||||
}
|
error.message.includes('EncodingError'))) {
|
||||||
};
|
console.log('Skipping problematic sentence:', sentence.substring(0, 50) + '...');
|
||||||
|
advance();
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Preload adjacent sentences when currentIndex changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
|
|
@ -470,7 +352,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Only preload next sentence to reduce API load
|
// Only preload next sentence to reduce API load
|
||||||
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
|
await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay
|
||||||
await preloadSentence(sentences[currentIndex + 1]);
|
await processSentence(sentences[currentIndex + 1], true); // True indicates preloading
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preloading adjacent sentences:', error);
|
console.error('Error preloading adjacent sentences:', error);
|
||||||
|
|
@ -479,94 +361,26 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
preloadAdjacentSentences();
|
preloadAdjacentSentences();
|
||||||
}, [currentIndex, sentences]);
|
}, [currentIndex, sentences]);
|
||||||
|
|
||||||
const isMounted = useRef(false);
|
// Add this useEffect before the return statement in TTSProvider
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
const playAudio = async () => {
|
||||||
if (isPlaying && sentences[currentIndex] && !isProcessing && isEffectActive) {
|
if (isPlaying && sentences[currentIndex] && !isProcessing) {
|
||||||
await processAndPlaySentence(sentences[currentIndex]);
|
await playSentence(sentences[currentIndex]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
playAudio();
|
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]);
|
}, [isPlaying, currentIndex, sentences, isProcessing]);
|
||||||
|
|
||||||
const preloadSentence = async (sentence: string) => {
|
// const playAudio = useCallback(async () => {
|
||||||
if (!audioContext || !openaiRef.current) return;
|
// if (isPlaying && sentences[currentIndex] && !isProcessing) {
|
||||||
if (audioCacheRef.current.has(sentence)) return; // Already cached
|
// await processAndPlaySentence(sentences[currentIndex]);
|
||||||
|
// }
|
||||||
try {
|
// }, [isPlaying, sentences, currentIndex, isProcessing]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
if (currentRequestRef.current) {
|
abortAudio();
|
||||||
currentRequestRef.current.abort();
|
|
||||||
currentRequestRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop current audio
|
|
||||||
if (activeHowl) {
|
|
||||||
activeHowl.stop();
|
|
||||||
setActiveHowl(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
|
|
@ -575,49 +389,19 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||||||
// Cancel any ongoing request
|
abortAudio();
|
||||||
if (currentRequestRef.current) {
|
|
||||||
currentRequestRef.current.abort();
|
// Set the states in the next tick to ensure clean state
|
||||||
currentRequestRef.current = null;
|
setTimeout(() => {
|
||||||
}
|
setCurrentIndex(index);
|
||||||
|
setIsPlaying(true);
|
||||||
// Stop current audio
|
}, 50);
|
||||||
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]);
|
|
||||||
|
|
||||||
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
||||||
// Cancel any ongoing request
|
abortAudio();
|
||||||
if (currentRequestRef.current) {
|
|
||||||
currentRequestRef.current.abort();
|
|
||||||
currentRequestRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop current audio
|
|
||||||
if (activeHowl) {
|
|
||||||
activeHowl.stop();
|
|
||||||
setActiveHowl(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
skipTriggeredRef.current = false;
|
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||||
|
|
@ -661,12 +445,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setText,
|
setText,
|
||||||
currentSentence: sentences[currentIndex] || '',
|
currentSentence: sentences[currentIndex] || '',
|
||||||
audioQueue,
|
audioQueue,
|
||||||
currentAudioIndex,
|
|
||||||
stop,
|
stop,
|
||||||
setCurrentIndex: setCurrentIndexWithoutPlay,
|
setCurrentIndex: setCurrentIndexWithoutPlay,
|
||||||
stopAndPlayFromIndex,
|
stopAndPlayFromIndex,
|
||||||
sentences,
|
sentences,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
setIsProcessing,
|
||||||
|
setIsPlaying,
|
||||||
speed,
|
speed,
|
||||||
setSpeed,
|
setSpeed,
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
|
|
@ -674,6 +459,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setVoice,
|
setVoice,
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
availableVoices,
|
availableVoices,
|
||||||
|
currentIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configIsLoading) {
|
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