589 lines
No EOL
18 KiB
TypeScript
589 lines
No EOL
18 KiB
TypeScript
'use client';
|
|
|
|
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
} from 'react';
|
|
import nlp from 'compromise';
|
|
import OpenAI from 'openai';
|
|
import { LRUCache } from 'lru-cache'; // Import LRUCache directly
|
|
import { useConfig } from './ConfigContext';
|
|
|
|
// Add type declarations
|
|
declare global {
|
|
interface Window {
|
|
webkitAudioContext: typeof AudioContext;
|
|
}
|
|
}
|
|
|
|
type AudioContextType = typeof window extends undefined
|
|
? never
|
|
: AudioContext | null;
|
|
|
|
interface TTSContextType {
|
|
isPlaying: boolean;
|
|
currentText: string;
|
|
togglePlay: () => void;
|
|
skipForward: () => void;
|
|
skipBackward: () => void;
|
|
setText: (text: string) => void;
|
|
currentSentence: string;
|
|
audioQueue: AudioBuffer[];
|
|
currentAudioIndex: number;
|
|
stop: () => void;
|
|
setCurrentIndex: (index: number) => void;
|
|
stopAndPlayFromIndex: (index: number) => void;
|
|
sentences: string[];
|
|
isProcessing: boolean;
|
|
speed: number;
|
|
setSpeed: (speed: number) => void;
|
|
setSpeedAndRestart: (speed: number) => void;
|
|
voice: string;
|
|
setVoice: (voice: string) => void;
|
|
setVoiceAndRestart: (voice: string) => void;
|
|
availableVoices: string[];
|
|
}
|
|
|
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
|
|
|
export function TTSProvider({ children }: { children: React.ReactNode }) {
|
|
const { apiKey: openApiKey, baseUrl: openApiBaseUrl, isLoading: configIsLoading } = useConfig();
|
|
|
|
// Move openai initialization to a ref to avoid breaking hooks rules
|
|
const openaiRef = useRef<OpenAI | null>(null);
|
|
|
|
// All existing state declarations and refs stay at the top
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
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 [activeSource, setActiveSource] = useState<AudioBufferSourceNode | null>(null);
|
|
const [audioQueue, setAudioQueue] = useState<AudioBuffer[]>([]);
|
|
const [currentAudioIndex, setCurrentAudioIndex] = 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[]>([]);
|
|
|
|
// Audio cache using LRUCache with a maximum size of 50 entries
|
|
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
|
|
|
|
// Initialize OpenAI instance when config loads
|
|
useEffect(() => {
|
|
const fetchVoices = async () => {
|
|
try {
|
|
const response = await fetch(`${openApiBaseUrl}/audio/voices`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${openApiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
if (!response.ok) throw new Error('Failed to fetch voices');
|
|
const data = await response.json();
|
|
setAvailableVoices(data.voices || []);
|
|
} catch (error) {
|
|
console.error('Error fetching voices:', error);
|
|
|
|
// Set available voices to default openai voices
|
|
// Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer
|
|
setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']);
|
|
}
|
|
};
|
|
|
|
if (!configIsLoading && openApiKey && openApiBaseUrl) {
|
|
openaiRef.current = new OpenAI({
|
|
apiKey: openApiKey,
|
|
baseURL: openApiBaseUrl,
|
|
dangerouslyAllowBrowser: true,
|
|
});
|
|
fetchVoices();
|
|
}
|
|
}, [configIsLoading, openApiKey, openApiBaseUrl]);
|
|
|
|
useEffect(() => {
|
|
/*
|
|
* Initializes the AudioContext for text-to-speech playback.
|
|
* Creates a new AudioContext instance if one doesn't exist.
|
|
* Only runs on the client side to avoid SSR issues.
|
|
*
|
|
* Dependencies:
|
|
* - audioContext: Re-runs if the audioContext is null or changes
|
|
*/
|
|
if (typeof window !== 'undefined' && !audioContext) {
|
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
if (AudioContextClass) {
|
|
try {
|
|
setAudioContext(new AudioContextClass());
|
|
} catch (error) {
|
|
console.error('Failed to initialize AudioContext:', error);
|
|
}
|
|
}
|
|
}
|
|
}, [audioContext]);
|
|
|
|
// Text preprocessing function to clean and normalize text
|
|
const preprocessText = (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 = preprocessText(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;
|
|
}
|
|
|
|
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]);
|
|
|
|
const processAndPlaySentence = async (sentence: string) => {
|
|
if (!audioContext || isProcessing || !openaiRef.current) return;
|
|
|
|
try {
|
|
// Only set processing if we need to fetch from API
|
|
const cleanedSentence = preprocessText(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();
|
|
|
|
// Stop any currently playing audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(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;
|
|
|
|
const source = audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(audioContext.destination);
|
|
setActiveSource(source);
|
|
|
|
// Set up onended handler before starting
|
|
source.onended = () => {
|
|
setActiveSource(null);
|
|
// Only advance if we're playing and not pausing or skipping
|
|
if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) {
|
|
processNextSentence();
|
|
}
|
|
isPausingRef.current = false; // Reset pause flag
|
|
};
|
|
|
|
source.start(0);
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
console.log('Request was cancelled');
|
|
} else {
|
|
console.error('Error processing TTS:', error);
|
|
}
|
|
setActiveSource(null);
|
|
setIsProcessing(false);
|
|
} finally {
|
|
currentRequestRef.current = null;
|
|
}
|
|
};
|
|
|
|
const togglePlay = useCallback(() => {
|
|
setIsPlaying((prev) => {
|
|
if (!prev) {
|
|
isPausingRef.current = false;
|
|
return true;
|
|
} else {
|
|
if (activeSource) {
|
|
isPausingRef.current = true;
|
|
activeSource.stop();
|
|
setActiveSource(null);
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
}, [activeSource]);
|
|
|
|
const skipForward = useCallback(() => {
|
|
if (skipTimeoutRef.current) {
|
|
clearTimeout(skipTimeoutRef.current);
|
|
}
|
|
|
|
skipTriggeredRef.current = true;
|
|
setIsProcessing(true);
|
|
// Cancel any ongoing request
|
|
if (currentRequestRef.current) {
|
|
currentRequestRef.current.abort();
|
|
currentRequestRef.current = null;
|
|
}
|
|
|
|
// Stop current audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(null);
|
|
}
|
|
|
|
setCurrentIndex((prev) => {
|
|
const nextIndex = Math.min(prev + 1, sentences.length - 1);
|
|
console.log('Skipping forward to:', sentences[nextIndex]);
|
|
return nextIndex;
|
|
});
|
|
|
|
// Reset skip flag after a short delay
|
|
skipTimeoutRef.current = setTimeout(() => {
|
|
skipTriggeredRef.current = false;
|
|
setIsProcessing(false);
|
|
}, 100);
|
|
}, [sentences, activeSource]);
|
|
|
|
const skipBackward = useCallback(() => {
|
|
if (skipTimeoutRef.current) {
|
|
clearTimeout(skipTimeoutRef.current);
|
|
}
|
|
|
|
skipTriggeredRef.current = true;
|
|
setIsProcessing(true);
|
|
// Cancel any ongoing request
|
|
if (currentRequestRef.current) {
|
|
currentRequestRef.current.abort();
|
|
currentRequestRef.current = null;
|
|
}
|
|
|
|
// Stop current audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(null);
|
|
}
|
|
|
|
setCurrentIndex((prev) => {
|
|
const nextIndex = Math.max(prev - 1, 0);
|
|
console.log('Skipping backward to:', sentences[nextIndex]);
|
|
return nextIndex;
|
|
});
|
|
|
|
// Reset skip flag after a short delay
|
|
skipTimeoutRef.current = setTimeout(() => {
|
|
skipTriggeredRef.current = false;
|
|
setIsProcessing(false);
|
|
}, 100);
|
|
}, [sentences, activeSource]);
|
|
|
|
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 (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(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
|
|
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
const stop = useCallback(() => {
|
|
// Cancel any ongoing request
|
|
if (currentRequestRef.current) {
|
|
currentRequestRef.current.abort();
|
|
currentRequestRef.current = null;
|
|
}
|
|
|
|
// Stop current audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(null);
|
|
}
|
|
|
|
setIsPlaying(false);
|
|
setCurrentIndex(0);
|
|
setCurrentText('');
|
|
setIsProcessing(false);
|
|
}, [activeSource]);
|
|
|
|
const stopAndPlayFromIndex = useCallback((index: number) => {
|
|
// Cancel any ongoing request
|
|
if (currentRequestRef.current) {
|
|
currentRequestRef.current.abort();
|
|
currentRequestRef.current = null;
|
|
}
|
|
|
|
// Stop current audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(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);
|
|
}, [activeSource]);
|
|
|
|
const setCurrentIndexWithoutPlay = useCallback((index: number) => {
|
|
// Cancel any ongoing request
|
|
if (currentRequestRef.current) {
|
|
currentRequestRef.current.abort();
|
|
currentRequestRef.current = null;
|
|
}
|
|
|
|
// Stop current audio
|
|
if (activeSource) {
|
|
activeSource.stop();
|
|
setActiveSource(null);
|
|
}
|
|
|
|
setCurrentIndex(index);
|
|
skipTriggeredRef.current = false;
|
|
}, [activeSource]);
|
|
|
|
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
|
setSpeed(newSpeed);
|
|
// Clear the audio cache since it contains audio at the old speed
|
|
audioCacheRef.current.clear();
|
|
|
|
if (isPlaying) {
|
|
const currentIdx = currentIndex;
|
|
stop();
|
|
// Small delay to ensure audio is fully stopped
|
|
setTimeout(() => {
|
|
setCurrentIndex(currentIdx);
|
|
setIsPlaying(true);
|
|
}, 50);
|
|
}
|
|
}, [isPlaying, currentIndex, stop]);
|
|
|
|
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
|
setVoice(newVoice);
|
|
// Clear the audio cache since it contains audio with the old voice
|
|
audioCacheRef.current.clear();
|
|
|
|
if (isPlaying) {
|
|
const currentIdx = currentIndex;
|
|
stop();
|
|
// Small delay to ensure audio is fully stopped
|
|
setTimeout(() => {
|
|
setCurrentIndex(currentIdx);
|
|
setIsPlaying(true);
|
|
}, 50);
|
|
}
|
|
}, [isPlaying, currentIndex, stop]);
|
|
|
|
const value = {
|
|
isPlaying,
|
|
currentText,
|
|
togglePlay,
|
|
skipForward,
|
|
skipBackward,
|
|
setText,
|
|
currentSentence: sentences[currentIndex] || '',
|
|
audioQueue,
|
|
currentAudioIndex,
|
|
stop,
|
|
setCurrentIndex: setCurrentIndexWithoutPlay,
|
|
stopAndPlayFromIndex,
|
|
sentences,
|
|
isProcessing,
|
|
speed,
|
|
setSpeed,
|
|
setSpeedAndRestart,
|
|
voice,
|
|
setVoice,
|
|
setVoiceAndRestart,
|
|
availableVoices,
|
|
};
|
|
|
|
if (configIsLoading) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<TTSContext.Provider value={value}>
|
|
{children}
|
|
</TTSContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTTS() {
|
|
const context = useContext(TTSContext);
|
|
if (context === undefined) {
|
|
throw new Error('useTTS must be used within a TTSProvider');
|
|
}
|
|
return context;
|
|
} |