diff --git a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx index df974db0e8..06b57cc882 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx @@ -8,20 +8,21 @@ import store from '~/store'; import { cn } from '~/utils'; import ConversationModeSwitch from './ConversationModeSwitch'; import { + CloudBrowserVoicesSwitch, + AutomaticPlaybackSwitch, TextToSpeechSwitch, EngineTTSDropdown, - AutomaticPlaybackSwitch, CacheTTSSwitch, VoiceDropdown, PlaybackRate, } from './TTS'; import { - DecibelSelector, - EngineSTTDropdown, + AutoTranscribeAudioSwitch, LanguageSTTDropdown, SpeechToTextSwitch, AutoSendTextSwitch, - AutoTranscribeAudioSwitch, + EngineSTTDropdown, + DecibelSelector, } from './STT'; import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query'; @@ -42,6 +43,9 @@ function Speech() { const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText); const [engineTTS, setEngineTTS] = useRecoilState(store.engineTTS); const [voice, setVoice] = useRecoilState(store.voice); + const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState( + store.cloudBrowserVoices, + ); const [languageTTS, setLanguageTTS] = useRecoilState(store.languageTTS); const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback); const [playbackRate, setPlaybackRate] = useRecoilState(store.playbackRate); @@ -61,6 +65,7 @@ function Speech() { autoSendText: { value: autoSendText, setFunc: setAutoSendText }, engineTTS: { value: engineTTS, setFunc: setEngineTTS }, voice: { value: voice, setFunc: setVoice }, + cloudBrowserVoices: { value: cloudBrowserVoices, setFunc: setCloudBrowserVoices }, languageTTS: { value: languageTTS, setFunc: setLanguageTTS }, automaticPlayback: { value: automaticPlayback, setFunc: setAutomaticPlayback }, playbackRate: { value: playbackRate, setFunc: setPlaybackRate }, @@ -86,6 +91,7 @@ function Speech() { autoSendText, engineTTS, voice, + cloudBrowserVoices, languageTTS, automaticPlayback, playbackRate, @@ -101,6 +107,7 @@ function Speech() { setAutoSendText, setEngineTTS, setVoice, + setCloudBrowserVoices, setLanguageTTS, setAutomaticPlayback, setPlaybackRate, @@ -168,27 +175,23 @@ function Speech() {
-
- -
-
-
+
-
+
-
+
-
+
@@ -196,47 +199,52 @@ function Speech() {
-
+
-
+
-
+
-
+
{autoTranscribeAudio && ( -
+
)} -
+
-
+
-
+
-
+
-
+ {engineTTS === 'browser' && ( +
+ +
+ )} +
-
+
diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx new file mode 100644 index 0000000000..c0667018a2 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx @@ -0,0 +1,37 @@ +import { useRecoilState } from 'recoil'; +import { Switch } from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +export default function CloudBrowserVoicesSwitch({ + onCheckedChange, +}: { + onCheckedChange?: (value: boolean) => void; +}) { + const localize = useLocalize(); + const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState( + store.cloudBrowserVoices, + ); + const [textToSpeech] = useRecoilState(store.textToSpeech); + + const handleCheckedChange = (value: boolean) => { + setCloudBrowserVoices(value); + if (onCheckedChange) { + onCheckedChange(value); + } + }; + + return ( +
+
{localize('com_nav_enable_cloud_browser_voice')}
+ +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/VoiceDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/VoiceDropdown.tsx index 7468cf5361..afe95b5fcf 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/VoiceDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/VoiceDropdown.tsx @@ -1,34 +1,73 @@ +import React, { useMemo, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; -import { useMemo, useEffect } from 'react'; import Dropdown from '~/components/ui/DropdownNoState'; import { useVoicesQuery } from '~/data-provider'; import { useLocalize } from '~/hooks'; import store from '~/store'; +const getLocalVoices = (): Promise => { + return new Promise((resolve) => { + const voices = speechSynthesis.getVoices(); + console.log('voices', voices); + + if (voices.length) { + resolve(voices); + } else { + speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); + } + }); +}; + +type VoiceOption = { + value: string; + display: string; +}; + export default function VoiceDropdown() { const localize = useLocalize(); const [voice, setVoice] = useRecoilState(store.voice); - const { data } = useVoicesQuery(); + const [engineTTS] = useRecoilState(store.engineTTS); + const [cloudBrowserVoices] = useRecoilState(store.cloudBrowserVoices); + const externalTextToSpeech = engineTTS === 'external'; + const { data: externalVoices = [] } = useVoicesQuery(); + const [localVoices, setLocalVoices] = useState([]); useEffect(() => { - if (!voice && data?.length) { - setVoice(data[0]); + if (!externalTextToSpeech) { + getLocalVoices().then(setLocalVoices); } - }, [voice, data, setVoice]); + }, [externalTextToSpeech]); - const voiceOptions = useMemo( - () => (data ?? []).map((v: string) => ({ value: v, display: v })), - [data], - ); + useEffect(() => { + if (voice) { + return; + } + + if (externalTextToSpeech && externalVoices.length) { + setVoice(externalVoices[0]); + } else if (!externalTextToSpeech && localVoices.length) { + setVoice(localVoices[0].name); + } + }, [voice, setVoice, externalTextToSpeech, externalVoices, localVoices]); + + const voiceOptions: VoiceOption[] = useMemo(() => { + if (externalTextToSpeech) { + return externalVoices.map((v) => ({ value: v, display: v })); + } else { + return localVoices + .filter((v) => cloudBrowserVoices || v.localService === true) + .map((v) => ({ value: v.name, display: v.name })); + } + }, [externalTextToSpeech, externalVoices, localVoices, cloudBrowserVoices]); return (
{localize('com_nav_voice_select')}
setVoice(value)} + onChange={setVoice} options={voiceOptions} - position={'left'} + position="left" testId="VoiceDropdown" />
diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/__tests__/CloudBrowserVoicesSwitch.spec.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/__tests__/CloudBrowserVoicesSwitch.spec.tsx new file mode 100644 index 0000000000..13d48a12b0 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/__tests__/CloudBrowserVoicesSwitch.spec.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { render, fireEvent } from 'test/layout-test-utils'; +import CloudBrowserVoicesSwitch from '../CloudBrowserVoicesSwitch'; +import { RecoilRoot } from 'recoil'; + +describe('CloudBrowserVoicesSwitch', () => { + /** + * Mock function to set the cache-tts state. + */ + let mockSetCloudBrowserVoices: + | jest.Mock + | ((value: boolean) => void) + | undefined; + + beforeEach(() => { + mockSetCloudBrowserVoices = jest.fn(); + }); + + it('renders correctly', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('CloudBrowserVoices')).toBeInTheDocument(); + }); + + it('calls onCheckedChange when the switch is toggled', () => { + const { getByTestId } = render( + + + , + ); + const switchElement = getByTestId('CloudBrowserVoices'); + fireEvent.click(switchElement); + + expect(mockSetCloudBrowserVoices).toHaveBeenCalledWith(true); + }); +}); diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/index.ts b/client/src/components/Nav/SettingsTabs/Speech/TTS/index.ts index b7b12cf15b..d34c7da76a 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/index.ts +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/index.ts @@ -1,6 +1,7 @@ +export { default as CloudBrowserVoicesSwitch } from './CloudBrowserVoicesSwitch'; export { default as AutomaticPlaybackSwitch } from './AutomaticPlaybackSwitch'; -export { default as CacheTTSSwitch } from './CacheTTSSwitch'; -export { default as EngineTTSDropdown } from './EngineTTSDropdown'; -export { default as PlaybackRate } from './PlaybackRate'; export { default as TextToSpeechSwitch } from './TextToSpeechSwitch'; +export { default as EngineTTSDropdown } from './EngineTTSDropdown'; +export { default as CacheTTSSwitch } from './CacheTTSSwitch'; export { default as VoiceDropdown } from './VoiceDropdown'; +export { default as PlaybackRate } from './PlaybackRate'; diff --git a/client/src/hooks/Input/useGetAudioSettings.tsx b/client/src/hooks/Input/useGetAudioSettings.ts similarity index 100% rename from client/src/hooks/Input/useGetAudioSettings.tsx rename to client/src/hooks/Input/useGetAudioSettings.ts diff --git a/client/src/hooks/Input/useTextToSpeechBrowser.ts b/client/src/hooks/Input/useTextToSpeechBrowser.ts index 8e54e8930c..d07fb608ce 100644 --- a/client/src/hooks/Input/useTextToSpeechBrowser.ts +++ b/client/src/hooks/Input/useTextToSpeechBrowser.ts @@ -1,12 +1,24 @@ +import { useRecoilState } from 'recoil'; import { useState } from 'react'; +import store from '~/store'; function useTextToSpeechBrowser() { + const [cloudBrowserVoices] = useRecoilState(store.cloudBrowserVoices); const [isSpeaking, setIsSpeaking] = useState(false); + const [voiceName] = useRecoilState(store.voice); const generateSpeechLocal = (text: string) => { const synth = window.speechSynthesis; + const voices = synth.getVoices().filter((v) => cloudBrowserVoices || v.localService === true); + const voice = voices.find((v) => v.name === voiceName); + + if (!voice) { + return; + } + synth.cancel(); const utterance = new SpeechSynthesisUtterance(text); + utterance.voice = voice; utterance.onend = () => { setIsSpeaking(false); }; diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index cb352c655e..e6330e6699 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -641,6 +641,7 @@ export default { com_nav_delete_cache_storage: 'Delete TTS cache storage', com_nav_enable_cache_tts: 'Enable cache TTS', com_nav_voice_select: 'Voice', + com_nav_enable_cloud_browser_voice: 'Use cloud-based voices', com_nav_info_enter_to_send: 'When enabled, pressing `ENTER` will send your message. When disabled, pressing Enter will add a new line, and you\'ll need to press `CTRL + ENTER` to send your message.', com_nav_info_save_draft: diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index f1bb6eb4cc..72826eafd9 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -50,6 +50,7 @@ const localStorageAtoms = { textToSpeech: atomWithLocalStorage('textToSpeech', true), engineTTS: atomWithLocalStorage('engineTTS', 'browser'), voice: atomWithLocalStorage('voice', ''), + cloudBrowserVoices: atomWithLocalStorage('cloudBrowserVoices', false), languageTTS: atomWithLocalStorage('languageTTS', ''), automaticPlayback: atomWithLocalStorage('automaticPlayback', false), playbackRate: atomWithLocalStorage('playbackRate', null),