| import { useRecoilValue } from 'recoil'; | |
| import { useState, useEffect, useCallback } from 'react'; | |
| import type { VoiceOption } from '~/common'; | |
| import store from '~/store'; | |
| function useTextToSpeechBrowser({ | |
| setIsSpeaking, | |
| }: { | |
| setIsSpeaking: React.Dispatch<React.SetStateAction<boolean>>; | |
| }) { | |
| const voiceName = useRecoilValue(store.voice); | |
| const [voices, setVoices] = useState<VoiceOption[]>([]); | |
| const cloudBrowserVoices = useRecoilValue(store.cloudBrowserVoices); | |
| const [isSpeechSynthesisSupported, setIsSpeechSynthesisSupported] = useState(true); | |
| const updateVoices = useCallback(() => { | |
| const synth = window.speechSynthesis as SpeechSynthesis | undefined; | |
| if (!synth) { | |
| setIsSpeechSynthesisSupported(false); | |
| return; | |
| } | |
| try { | |
| const availableVoices = synth.getVoices(); | |
| if (!Array.isArray(availableVoices)) { | |
| console.error('getVoices() did not return an array'); | |
| return; | |
| } | |
| const filteredVoices = availableVoices.filter( | |
| (v) => cloudBrowserVoices || v.localService === true, | |
| ); | |
| const voiceOptions: VoiceOption[] = filteredVoices.map((v) => ({ | |
| value: v.name, | |
| label: v.name, | |
| })); | |
| setVoices(voiceOptions); | |
| } catch (error) { | |
| console.error('Error updating voices:', error); | |
| setIsSpeechSynthesisSupported(false); | |
| } | |
| }, [cloudBrowserVoices]); | |
| useEffect(() => { | |
| const synth = window.speechSynthesis as SpeechSynthesis | undefined; | |
| if (!synth) { | |
| setIsSpeechSynthesisSupported(false); | |
| return; | |
| } | |
| try { | |
| if (synth.getVoices().length) { | |
| updateVoices(); | |
| } else { | |
| synth.onvoiceschanged = updateVoices; | |
| } | |
| } catch (error) { | |
| console.error('Error in useEffect:', error); | |
| setIsSpeechSynthesisSupported(false); | |
| } | |
| return () => { | |
| if (synth.onvoiceschanged) { | |
| synth.onvoiceschanged = null; | |
| } | |
| }; | |
| }, [updateVoices]); | |
| const generateSpeechLocal = (text: string) => { | |
| if (!isSpeechSynthesisSupported) { | |
| console.warn('Speech synthesis is not supported'); | |
| return; | |
| } | |
| const synth = window.speechSynthesis; | |
| const voice = voices.find((v) => v.value === voiceName); | |
| if (!voice) { | |
| console.warn('Selected voice not found'); | |
| return; | |
| } | |
| try { | |
| synth.cancel(); | |
| const utterance = new SpeechSynthesisUtterance(text); | |
| utterance.voice = synth.getVoices().find((v) => v.name === voice.value) || null; | |
| utterance.onend = () => { | |
| setIsSpeaking(false); | |
| }; | |
| utterance.onerror = (event) => { | |
| console.error('Speech synthesis error:', event); | |
| setIsSpeaking(false); | |
| }; | |
| setIsSpeaking(true); | |
| synth.speak(utterance); | |
| } catch (error) { | |
| console.error('Error generating speech:', error); | |
| setIsSpeaking(false); | |
| } | |
| }; | |
| const cancelSpeechLocal = () => { | |
| if (!isSpeechSynthesisSupported) { | |
| return; | |
| } | |
| try { | |
| window.speechSynthesis.cancel(); | |
| } catch (error) { | |
| console.error('Error cancelling speech:', error); | |
| } finally { | |
| setIsSpeaking(false); | |
| } | |
| }; | |
| return { generateSpeechLocal, cancelSpeechLocal, voices, isSpeechSynthesisSupported }; | |
| } | |
| export default useTextToSpeechBrowser; | |