From 055e87e8c1f2e542ca71f263a067dbf288f0b411 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Tue, 28 Jan 2025 21:38:44 -0700 Subject: [PATCH] Extract TTS extras to hooks --- src/contexts/PDFContext.tsx | 2 +- src/contexts/TTSContext.tsx | 142 ++++++-------------------------- src/hooks/useAudioCache.ts | 18 ++++ src/hooks/useAudioContext.ts | 37 +++++++++ src/hooks/useMediaSession.ts | 28 +++++++ src/hooks/useVoiceManagement.ts | 35 ++++++++ 6 files changed, 145 insertions(+), 117 deletions(-) create mode 100644 src/hooks/useAudioCache.ts create mode 100644 src/hooks/useAudioContext.ts create mode 100644 src/hooks/useMediaSession.ts create mode 100644 src/hooks/useVoiceManagement.ts diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 5aa0fa1..c81d333 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -311,7 +311,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { } catch (error) { console.error('Failed to get document URL:', error); } - }, [indexedDBService]); + }, []); /** * Clears the current document state diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index b2d8303..d0aea62 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -24,12 +24,15 @@ import React, { useMemo, } from 'react'; import OpenAI from 'openai'; -import { LRUCache } from 'lru-cache'; // Import LRUCache directly import { Howl } from 'howler'; import { useConfig } from '@/contexts/ConfigContext'; import { splitIntoSentences, preprocessSentenceForAudio } from '@/services/nlp'; import { audioBufferToURL } from '@/services/audio'; +import { useAudioCache } from '@/hooks/useAudioCache'; +import { useVoiceManagement } from '@/hooks/useVoiceManagement'; +import { useMediaSession } from '@/hooks/useMediaSession'; +import { useAudioContext } from '@/hooks/useAudioContext'; // Media globals declare global { @@ -38,13 +41,6 @@ declare global { } } -/** - * Type definition for AudioContext to handle browser compatibility - */ -type AudioContextType = typeof window extends undefined - ? never - : (AudioContext); - /** * Interface defining all available methods and properties in the TTS context */ @@ -97,36 +93,25 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { // OpenAI client reference const openaiRef = useRef(null); + // Use custom hooks + const audioContext = useAudioContext(); + const audioCache = useAudioCache(50); + const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl); + /** * State Management - * - Playback control - * - Text and sentence management - * - Audio processing - * - Voice and speed settings - * - Document navigation */ - // All existing state declarations and refs stay at the top const [isPlaying, setIsPlaying] = useState(false); - const [currentText, setCurrentText] = useState(''); const [sentences, setSentences] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); - const [audioContext, setAudioContext] = useState(); const [activeHowl, setActiveHowl] = useState(null); - const [audioQueue] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [speed, setSpeed] = useState(voiceSpeed); const [voice, setVoice] = useState(configVoice); - const [availableVoices, setAvailableVoices] = useState([]); - const [currDocPage, setCurrDocPage] = useState(1); const [currDocPages, setCurrDocPages] = useState(); const [nextPageLoading, setNextPageLoading] = useState(false); - /** - * Cache for storing audio buffers using LRU (Least Recently Used) strategy - */ - const audioCacheRef = useRef(new LRUCache({ max: 50 })); - /** * Sets the current text and splits it into sentences * @@ -134,14 +119,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { * @returns {void} */ 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); }, []); @@ -230,7 +211,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { return prev; }); }, [sentences, currDocPage, currDocPages, incrementPage]); - /** * Moves forward one sentence in the text @@ -274,30 +254,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { /** * Initializes OpenAI configuration and fetches available voices - * - * This effect runs when the config is loaded or API settings change */ 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, @@ -307,49 +265,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { fetchVoices(); updateVoiceAndSpeed(); } - }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed]); - - /** - * Initializes the AudioContext when component mounts - */ - useEffect(() => { - 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); - } - } - } - - return () => { - if (audioContext) { - audioContext.close().catch((error) => { - console.error('Error closing AudioContext:', error); - }); - } - } - }, [audioContext]); - - /** - * Sets up MediaSession API for media controls - */ - useEffect(() => { - if ('mediaSession' in navigator) { - navigator.mediaSession.metadata = new MediaMetadata({ - title: 'Text-to-Speech', - artist: 'OpenReader WebUI', - album: 'Current Document', - }); - - navigator.mediaSession.setActionHandler('play', () => togglePlay()); - navigator.mediaSession.setActionHandler('pause', () => togglePlay()); - navigator.mediaSession.setActionHandler('nexttrack', () => skipForward()); - navigator.mediaSession.setActionHandler('previoustrack', () => skipBackward()); - } - }, [togglePlay, skipForward, skipBackward]); + }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices]); /** * Generates and plays audio for the current sentence @@ -358,7 +274,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { */ const getAudio = useCallback(async (sentence: string): Promise => { // Check if the audio is already cached - const cachedAudio = audioCacheRef.current.get(sentence); + const cachedAudio = audioCache.get(sentence); if (cachedAudio) { console.log('Using cached audio for sentence:', sentence.substring(0, 20)); return cachedAudio; @@ -379,11 +295,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const audioBuffer = await audioContext!.decodeAudioData(arrayBuffer); // Cache the audio buffer - audioCacheRef.current.set(sentence, audioBuffer); + audioCache.set(sentence, audioBuffer); return audioBuffer; } - }, [audioContext, voice, speed]); + }, [audioContext, voice, speed, audioCache]); /** * Processes and plays the current sentence @@ -467,13 +383,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { */ const preloadNextAudio = useCallback(() => { try { - if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) { + if (sentences[currentIndex + 1] && !audioCache.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]); + }, [currentIndex, sentences, audioCache, processSentence]); /** * Plays the current sentence's audio @@ -527,7 +443,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setIsPlaying(false); setCurrentIndex(0); - setCurrentText(''); setIsProcessing(false); }, [abortAudio]); @@ -544,18 +459,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setIsPlaying(true); }, [abortAudio]); - /** - * Sets the current index without playing - * - * @param {number} index - The index to set - * @returns {void} - */ - const setCurrentIndexWithoutPlay = useCallback((index: number) => { - abortAudio(); - - setCurrentIndex(index); - }, [abortAudio]); - /** * Sets the speed and restarts the playback * @@ -566,14 +469,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setSpeed(newSpeed); updateConfigKey('voiceSpeed', newSpeed); // Clear the audio cache since it contains audio at the old speed - audioCacheRef.current.clear(); + audioCache.clear(); if (isPlaying) { setIsPlaying(false); abortAudio(); setIsPlaying(true); } - }, [isPlaying, abortAudio, updateConfigKey]); + }, [isPlaying, abortAudio, updateConfigKey, audioCache]); /** * Sets the voice and restarts the playback @@ -585,14 +488,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { setVoice(newVoice); updateConfigKey('voice', newVoice); // Clear the audio cache since it contains audio with the old voice - audioCacheRef.current.clear(); + audioCache.clear(); if (isPlaying) { setIsPlaying(false); abortAudio(); setIsPlaying(true); } - }, [isPlaying, abortAudio, updateConfigKey]); + }, [isPlaying, abortAudio, updateConfigKey, audioCache]); /** * Provides the TTS context value to child components @@ -634,6 +537,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { skipToPage, ]); + // Use media session hook + useMediaSession({ + togglePlay, + skipForward, + skipBackward, + }); + /** * Renders the TTS context provider with its children * diff --git a/src/hooks/useAudioCache.ts b/src/hooks/useAudioCache.ts new file mode 100644 index 0000000..9220d2e --- /dev/null +++ b/src/hooks/useAudioCache.ts @@ -0,0 +1,18 @@ +import { useRef } from 'react'; +import { LRUCache } from 'lru-cache'; + +/** + * Custom hook for managing audio cache using LRU strategy + * @param maxSize Maximum number of items to store in cache + * @returns Object containing cache methods + */ +export function useAudioCache(maxSize = 50) { + const cacheRef = useRef(new LRUCache({ max: maxSize })); + + return { + get: (key: string) => cacheRef.current.get(key), + set: (key: string, value: AudioBuffer) => cacheRef.current.set(key, value), + has: (key: string) => cacheRef.current.has(key), + clear: () => cacheRef.current.clear(), + }; +} diff --git a/src/hooks/useAudioContext.ts b/src/hooks/useAudioContext.ts new file mode 100644 index 0000000..fc936a4 --- /dev/null +++ b/src/hooks/useAudioContext.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; + +// Type definition for AudioContext to handle browser compatibility +type AudioContextType = typeof window extends undefined + ? never + : (AudioContext); + +/** + * Custom hook for managing AudioContext + * @returns AudioContext instance or undefined + */ +export function useAudioContext() { + const [audioContext, setAudioContext] = useState(); + + useEffect(() => { + 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); + } + } + } + + return () => { + if (audioContext) { + audioContext.close().catch((error) => { + console.error('Error closing AudioContext:', error); + }); + } + }; + }, [audioContext]); + + return audioContext; +} diff --git a/src/hooks/useMediaSession.ts b/src/hooks/useMediaSession.ts new file mode 100644 index 0000000..87c3a76 --- /dev/null +++ b/src/hooks/useMediaSession.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; + +interface MediaControls { + togglePlay: () => void; + skipForward: () => void; + skipBackward: () => void; +} + +/** + * Custom hook for managing media session controls + * @param controls Object containing media control functions + */ +export function useMediaSession(controls: MediaControls) { + useEffect(() => { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: 'Text-to-Speech', + artist: 'OpenReader WebUI', + album: 'Current Document', + }); + + navigator.mediaSession.setActionHandler('play', () => controls.togglePlay()); + navigator.mediaSession.setActionHandler('pause', () => controls.togglePlay()); + navigator.mediaSession.setActionHandler('nexttrack', () => controls.skipForward()); + navigator.mediaSession.setActionHandler('previoustrack', () => controls.skipBackward()); + } + }, [controls]); +} diff --git a/src/hooks/useVoiceManagement.ts b/src/hooks/useVoiceManagement.ts new file mode 100644 index 0000000..312744f --- /dev/null +++ b/src/hooks/useVoiceManagement.ts @@ -0,0 +1,35 @@ +import { useState, useCallback } from 'react'; + +const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; + +/** + * Custom hook for managing TTS voices + * @param apiKey OpenAI API key + * @param baseUrl OpenAI API base URL + * @returns Object containing available voices and fetch function + */ +export function useVoiceManagement(apiKey: string | undefined, baseUrl: string | undefined) { + const [availableVoices, setAvailableVoices] = useState([]); + + const fetchVoices = useCallback(async () => { + if (!apiKey || !baseUrl) return; + + try { + const response = await fetch(`${baseUrl}/audio/voices`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + '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 + setAvailableVoices(DEFAULT_VOICES); + } + }, [apiKey, baseUrl]); + + return { availableVoices, fetchVoices }; +}