diff --git a/app/api/elevenlabs/speech/route.ts b/app/api/elevenlabs/speech/route.ts index d7a8d6e7b5..7a9f21678c 100644 --- a/app/api/elevenlabs/speech/route.ts +++ b/app/api/elevenlabs/speech/route.ts @@ -1,2 +1,2 @@ export const runtime = 'edge'; -export { elevenLabsHandler as POST } from '~/modules/elevenlabs/elevenlabs.server'; \ No newline at end of file +export { elevenLabsHandler as POST } from '~/modules/tts/vendors/elevenlabs/elevenlabs.server'; \ No newline at end of file diff --git a/pages/info/debug.tsx b/pages/info/debug.tsx index d451a15077..8b27709b94 100644 --- a/pages/info/debug.tsx +++ b/pages/info/debug.tsx @@ -18,7 +18,8 @@ import { ROUTE_APP_CHAT, ROUTE_INDEX } from '~/common/app.routes'; import { Release } from '~/common/app.release'; // capabilities access -import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities'; +import { useCapabilityBrowserSpeechRecognition, useCapabilityTextToImage } from '~/common/components/useCapabilities'; +import { useTTSCapability } from '~/modules/tts/tts.client.hooks'; // stores access import { getLLMsDebugInfo } from '~/common/stores/llms/store-llms'; @@ -95,7 +96,7 @@ function AppDebug() { const cProduct = { capabilities: { mic: useCapabilityBrowserSpeechRecognition(), - elevenLabs: useCapabilityElevenLabs(), + elevenLabs: useTTSCapability(), textToImage: useCapabilityTextToImage(), }, models: getLLMsDebugInfo(), diff --git a/src/apps/call/CallWizard.tsx b/src/apps/call/CallWizard.tsx index ab8a7ad6f5..190f76ab4d 100644 --- a/src/apps/call/CallWizard.tsx +++ b/src/apps/call/CallWizard.tsx @@ -12,11 +12,13 @@ import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; import { animationColorRainbow } from '~/common/util/animUtils'; import { navigateBack } from '~/common/app.routes'; import { optimaOpenPreferences } from '~/common/layout/optima/useOptima'; -import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities'; +import { useCapabilityBrowserSpeechRecognition } from '~/common/components/useCapabilities'; +import { useTTSCapability } from '~/modules/tts/tts.client.hooks'; import { useChatStore } from '~/common/stores/chat/store-chats'; import { useUICounter } from '~/common/state/store-ui'; + function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) { return ( @@ -45,7 +47,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n // external state const recognition = useCapabilityBrowserSpeechRecognition(); - const synthesis = useCapabilityElevenLabs(); + const synthesis = useTTSCapability(); const chatIsEmpty = useChatStore(state => { if (!props.conversationId) return false; diff --git a/src/apps/call/Telephone.tsx b/src/apps/call/Telephone.tsx index 0b1102bd80..72c7285156 100644 --- a/src/apps/call/Telephone.tsx +++ b/src/apps/call/Telephone.tsx @@ -13,10 +13,10 @@ import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom'; import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton'; import { useChatLLMDropdown } from '../chat/components/layout-bar/useLLMDropdown'; -import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client'; +import { EXPERIMENTAL_speakTextStream } from '~/modules/tts/tts.client'; import { SystemPurposeId, SystemPurposes } from '../../data'; import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client'; -import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown'; +import { TTSSetting } from '~/modules/tts/tts.setting'; import type { OptimaBarControlMethods } from '~/common/layout/optima/bar/OptimaBarDropdown'; import { AudioPlayer } from '~/common/util/audio/AudioPlayer'; @@ -39,6 +39,7 @@ import { CallStatus } from './components/CallStatus'; import { useAppCallStore } from './state/store-app-call'; + function CallMenuItems(props: { pushToTalk: boolean, setPushToTalk: (pushToTalk: boolean) => void, @@ -48,8 +49,7 @@ function CallMenuItems(props: { // external state const { grayUI, toggleGrayUI } = useAppCallStore(); - const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override); - + const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk); const handleChangeVoiceToggle = () => props.setOverride(!props.override); @@ -68,10 +68,10 @@ function CallMenuItems(props: { - - {' '} - {voicesDropdown} + + + @@ -245,13 +245,22 @@ export function Telephone(props: { // perform completion responseAbortController.current = new AbortController(); let finalText = ''; + let currentSentence = ''; let error: any | null = null; setPersonaTextInterim('💭...'); llmStreamingChatGenerate(chatLLMId, callPrompt, 'call', callMessages[0].id, null, null, responseAbortController.current.signal, ({ textSoFar }) => { const text = textSoFar?.trim(); if (text) { - finalText = text; setPersonaTextInterim(text); + + // Maintain and say the current sentence + if (/[.,!?]$/.test(text)) { + currentSentence = text.substring(finalText?.length) + finalText = text + if (currentSentence?.length >= 1) + void EXPERIMENTAL_speakTextStream(currentSentence, personaVoiceId); + } + currentSentence = text.substring(finalText?.length) // to be added to the final text } }).catch((err: DOMException) => { if (err?.name !== 'AbortError') @@ -261,8 +270,8 @@ export function Telephone(props: { if (finalText || error) setCallMessages(messages => [...messages, createDMessageTextContent('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]); // [state] append assistant:call_response // fire/forget - if (finalText?.length >= 1) - void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId); + if (currentSentence?.length >= 1) + void EXPERIMENTAL_speakTextStream(currentSentence, personaVoiceId); }); return () => { diff --git a/src/apps/chat/AppChat.tsx b/src/apps/chat/AppChat.tsx index b58c54767f..a21dd584e8 100644 --- a/src/apps/chat/AppChat.tsx +++ b/src/apps/chat/AppChat.tsx @@ -10,7 +10,7 @@ import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal'; import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal'; import { downloadSingleChat, importConversationsFromFilesAtRest, openConversationsAtRestPicker } from '~/modules/trade/trade.client'; import { imaginePromptFromTextOrThrow } from '~/modules/aifn/imagine/imaginePromptFromText'; -import { speakText } from '~/modules/elevenlabs/elevenlabs.client'; +import { speakText } from '~/modules/tts/tts.client'; import { useAreBeamsOpen } from '~/modules/beam/store-beam.hooks'; import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client'; diff --git a/src/apps/chat/components/ChatMessageList.tsx b/src/apps/chat/components/ChatMessageList.tsx index 534ee7e498..2b0df25fe5 100644 --- a/src/apps/chat/components/ChatMessageList.tsx +++ b/src/apps/chat/components/ChatMessageList.tsx @@ -19,7 +19,7 @@ import { getConversation, useChatStore } from '~/common/stores/chat/store-chats' import { openFileForAttaching } from '~/common/components/ButtonAttachFiles'; import { optimaOpenPreferences } from '~/common/layout/optima/useOptima'; import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating'; -import { useCapabilityElevenLabs } from '~/common/components/useCapabilities'; +import { useTTSCapability } from '~/modules/tts/tts.client.hooks'; import { useChatOverlayStore } from '~/common/chat-overlay/store-perchat_vanilla'; import { useScrollToBottom } from '~/common/scroll-to-bottom/useScrollToBottom'; @@ -30,6 +30,7 @@ import { PersonaSelector } from './persona-selector/PersonaSelector'; import { useChatAutoSuggestHTMLUI, useChatShowSystemMessages } from '../store-app-chat'; + const stableNoMessages: DMessage[] = []; /** @@ -75,7 +76,7 @@ export function ChatMessageList(props: { _composerInReferenceToCount: state.inReferenceTo?.length ?? 0, ephemerals: state.ephemerals?.length ? state.ephemerals : null, }))); - const { mayWork: isSpeakable } = useCapabilityElevenLabs(); + const { mayWork: isSpeakable } = useTTSCapability(); // derived state const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props; diff --git a/src/apps/chat/editors/persona/PersonaChatMessageSpeak.ts b/src/apps/chat/editors/persona/PersonaChatMessageSpeak.ts index a016af1dab..6c753f771d 100644 --- a/src/apps/chat/editors/persona/PersonaChatMessageSpeak.ts +++ b/src/apps/chat/editors/persona/PersonaChatMessageSpeak.ts @@ -1,4 +1,4 @@ -import { speakText } from '~/modules/elevenlabs/elevenlabs.client'; +import { speakText } from '~/modules/tts/tts.client'; import { isTextContentFragment } from '~/common/stores/chat/chat.fragments'; diff --git a/src/apps/chat/store-app-chat.ts b/src/apps/chat/store-app-chat.ts index f3fcc163ed..8a723af0ce 100644 --- a/src/apps/chat/store-app-chat.ts +++ b/src/apps/chat/store-app-chat.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; import type { DLLMId } from '~/common/stores/llms/llms.types'; +import { ASREngineKey, ASREngineList } from '~/modules/asr/asr.client'; export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all'; @@ -51,6 +52,9 @@ interface AppChatStore { micTimeoutMs: number; setMicTimeoutMs: (micTimeoutMs: number) => void; + ASREngine: ASREngineKey; + setASREngine: (ASREngine: ASREngineKey) => void; + showPersonaIcons: boolean; setShowPersonaIcons: (showPersonaIcons: boolean) => void; @@ -114,6 +118,9 @@ const useAppChatStore = create()(persist( micTimeoutMs: 2000, setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }), + ASREngine: ASREngineList[0].key, + setASREngine: (ASREngine: ASREngineKey) => _set({ ASREngine }), + showPersonaIcons: true, setShowPersonaIcons: (showPersonaIcons: boolean) => _set({ showPersonaIcons }), @@ -198,6 +205,9 @@ export const useChatMicTimeoutMsValue = (): number => export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] => useAppChatStore(useShallow(state => [state.micTimeoutMs, state.setMicTimeoutMs])); +export const useASREngine = (): [ASREngineKey, (ASREngine: ASREngineKey) => void] => + useAppChatStore(useShallow(state => [state.ASREngine, state.setASREngine])); + export const useChatDrawerFilters = () => { const values = useAppChatStore(useShallow(state => ({ filterHasDocFragments: state.filterHasDocFragments, diff --git a/src/apps/settings-modal/SettingsModal.tsx b/src/apps/settings-modal/SettingsModal.tsx index a306805398..fbecf18229 100644 --- a/src/apps/settings-modal/SettingsModal.tsx +++ b/src/apps/settings-modal/SettingsModal.tsx @@ -9,7 +9,6 @@ import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; import { BrowseSettings } from '~/modules/browse/BrowseSettings'; import { DallESettings } from '~/modules/t2i/dalle/DallESettings'; -import { ElevenlabsSettings } from '~/modules/elevenlabs/ElevenlabsSettings'; import { GoogleSearchSettings } from '~/modules/google/GoogleSearchSettings'; import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings'; import { T2ISettings } from '~/modules/t2i/T2ISettings'; @@ -22,6 +21,9 @@ import { AppChatSettingsAI } from './AppChatSettingsAI'; import { AppChatSettingsUI } from './settings-ui/AppChatSettingsUI'; import { UxLabsSettings } from './UxLabsSettings'; import { VoiceSettings } from './VoiceSettings'; +import { useTTSEngine } from '~/modules/tts/useTTSStore'; +import { TTSSetting } from '~/modules/tts/tts.setting'; +import { getName as getTTSEngineName } from '~/modules/tts/tts.client'; // styled into a Topics component @@ -122,6 +124,8 @@ export function SettingsModal(props: { // external state const isMobile = useIsMobile(); + const [TTSEngine] = useTTSEngine() + // handlers const { setTab } = props; @@ -193,8 +197,8 @@ export function SettingsModal(props: { - - + + diff --git a/src/apps/settings-modal/VoiceSettings.tsx b/src/apps/settings-modal/VoiceSettings.tsx index 404f15c594..f33658a571 100644 --- a/src/apps/settings-modal/VoiceSettings.tsx +++ b/src/apps/settings-modal/VoiceSettings.tsx @@ -1,63 +1,121 @@ import * as React from 'react'; -import { FormControl } from '@mui/joy'; +import { FormControl, Option, Select } from '@mui/joy'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { useChatAutoAI, useChatMicTimeoutMs } from '../chat/store-app-chat'; - -import { useElevenLabsVoices } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown'; +import { useASREngine, useChatAutoAI, useChatMicTimeoutMs } from '../chat/store-app-chat'; import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; import { FormRadioControl } from '~/common/components/forms/FormRadioControl'; import { LanguageSelect } from '~/common/components/LanguageSelect'; import { useIsMobile } from '~/common/components/useMatchMedia'; - +import { ASREngineKey, ASREngineList } from '~/modules/asr/asr.client'; +import { TTSEngineKey, TTSEngineList, useTTSEngine } from '~/modules/tts/useTTSStore'; +import { useTTSCapability } from '~/modules/tts/tts.client.hooks'; export function VoiceSettings() { - // external state const isMobile = useIsMobile(); const { autoSpeak, setAutoSpeak } = useChatAutoAI(); - const { hasVoices } = useElevenLabsVoices(); - const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs(); + const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs(); + const [TTSEngine, setTTSEngine] = useTTSEngine(); + const [ASREngine, setASREngine] = useASREngine(); // this converts from string keys to numbers and vice versa const chatTimeoutValue: string = '' + chatTimeoutMs; const setChatTimeoutValue = (value: string) => value && setChatTimeoutMs(parseInt(value)); - return <> - - {/* LanguageSelect: moved from the UI settings (where it logically belongs), just to group things better from an UX perspective */} - - - - - - {!isMobile && 5000 ? 'Best for thinking' : 'Standard'} - options={[ - { value: '600', label: '.6s' }, - { value: '2000', label: '2s' }, - { value: '15000', label: '15s' }, - ]} - value={chatTimeoutValue} onChange={setChatTimeoutValue} - />} - - - - ; -} \ No newline at end of file + const { mayWork: hasVoices } = useTTSCapability(); + + const handleTTSChanged = (_event: any, newValue: TTSEngineKey | null) => { + if (!newValue) return; + setTTSEngine(newValue); + }; + + const handleASRChanged = (_event: any, newValue: ASREngineKey | null) => { + if (!newValue) return; + setASREngine(newValue); + }; + + return ( + <> + {/* LanguageSelect: moved from the UI settings (where it logically belongs), just to group things better from an UX perspective */} + + + + + + {!isMobile && ( + 5000 ? 'Best for thinking' : 'Standard'} + options={[ + { value: '600', label: '.6s' }, + { value: '2000', label: '2s' }, + { value: '15000', label: '15s' }, + ]} + value={chatTimeoutValue} + onChange={setChatTimeoutValue} + /> + )} + + + + + + + + + + + + + + + ); +} diff --git a/src/common/components/useCapabilities.ts b/src/common/components/useCapabilities.ts index 2e11990b60..59b0c51b06 100644 --- a/src/common/components/useCapabilities.ts +++ b/src/common/components/useCapabilities.ts @@ -21,18 +21,6 @@ export interface CapabilityBrowserSpeechRecognition { export { browserSpeechRecognitionCapability as useCapabilityBrowserSpeechRecognition } from './speechrecognition/useSpeechRecognition'; - -/// Speech Synthesis: ElevenLabs - -export interface CapabilityElevenLabsSpeechSynthesis { - mayWork: boolean; - isConfiguredServerSide: boolean; - isConfiguredClientSide: boolean; -} - -export { useCapability as useCapabilityElevenLabs } from '~/modules/elevenlabs/elevenlabs.client'; - - /// Image Generation export interface TextToImageProvider { diff --git a/src/modules/asr/asr.client.ts b/src/modules/asr/asr.client.ts new file mode 100644 index 0000000000..30db9cf250 --- /dev/null +++ b/src/modules/asr/asr.client.ts @@ -0,0 +1,8 @@ +export type ASREngineKey = 'webspeech'; + +export const ASREngineList: { key: ASREngineKey; label: string }[] = [ + { + key: 'webspeech', + label: 'Web Speech API', + }, +]; diff --git a/src/modules/elevenlabs/elevenlabs.client.ts b/src/modules/elevenlabs/elevenlabs.client.ts deleted file mode 100644 index 7145cbdb15..0000000000 --- a/src/modules/elevenlabs/elevenlabs.client.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities'; - -import { AudioLivePlayer } from '~/common/util/audio/AudioLivePlayer'; -import { AudioPlayer } from '~/common/util/audio/AudioPlayer'; -import { CapabilityElevenLabsSpeechSynthesis } from '~/common/components/useCapabilities'; -import { frontendSideFetch } from '~/common/util/clientFetchers'; -import { useUIPreferencesStore } from '~/common/state/store-ui'; - -import type { SpeechInputSchema } from './elevenlabs.router'; -import { getElevenLabsData, useElevenLabsData } from './store-module-elevenlabs'; - - -export const isValidElevenLabsApiKey = (apiKey?: string) => !!apiKey && apiKey.trim()?.length >= 32; - -export const isElevenLabsEnabled = (apiKey?: string) => apiKey - ? isValidElevenLabsApiKey(apiKey) - : getBackendCapabilities().hasVoiceElevenLabs; - - -export function useCapability(): CapabilityElevenLabsSpeechSynthesis { - const [clientApiKey, voiceId] = useElevenLabsData(); - const isConfiguredServerSide = getBackendCapabilities().hasVoiceElevenLabs; - const isConfiguredClientSide = clientApiKey ? isValidElevenLabsApiKey(clientApiKey) : false; - const mayWork = isConfiguredServerSide || isConfiguredClientSide || !!voiceId; - return { mayWork, isConfiguredServerSide, isConfiguredClientSide }; -} - - -export async function speakText(text: string, voiceId?: string) { - if (!(text?.trim())) return; - - const { elevenLabsApiKey, elevenLabsVoiceId } = getElevenLabsData(); - if (!isElevenLabsEnabled(elevenLabsApiKey)) return; - - const { preferredLanguage } = useUIPreferencesStore.getState(); - const nonEnglish = !(preferredLanguage?.toLowerCase()?.startsWith('en')); - - try { - const edgeResponse = await frontendFetchAPIElevenLabsSpeech(text, elevenLabsApiKey, voiceId || elevenLabsVoiceId, nonEnglish, false); - const audioBuffer = await edgeResponse.arrayBuffer(); - await AudioPlayer.playBuffer(audioBuffer); - } catch (error) { - console.error('Error playing first text:', error); - } -} - -// let liveAudioPlayer: LiveAudioPlayer | undefined = undefined; - -export async function EXPERIMENTAL_speakTextStream(text: string, voiceId?: string) { - if (!(text?.trim())) return; - - const { elevenLabsApiKey, elevenLabsVoiceId } = getElevenLabsData(); - if (!isElevenLabsEnabled(elevenLabsApiKey)) return; - - const { preferredLanguage } = useUIPreferencesStore.getState(); - const nonEnglish = !(preferredLanguage?.toLowerCase()?.startsWith('en')); - - try { - const edgeResponse = await frontendFetchAPIElevenLabsSpeech(text, elevenLabsApiKey, voiceId || elevenLabsVoiceId, nonEnglish, true); - - // if (!liveAudioPlayer) - const liveAudioPlayer = new AudioLivePlayer(); - // fire/forget - void liveAudioPlayer.EXPERIMENTAL_playStream(edgeResponse); - - } catch (error) { - // has happened once in months of testing, not sure what was the cause - console.error('EXPERIMENTAL_speakTextStream:', error); - } -} - - -/** - * Note: we have to use this client-side API instead of TRPC because of ArrayBuffers.. - */ -async function frontendFetchAPIElevenLabsSpeech(text: string, elevenLabsApiKey: string, elevenLabsVoiceId: string, nonEnglish: boolean, streaming: boolean): Promise { - // NOTE: hardcoded 1000 as a failsafe, since the API will take very long and consume lots of credits for longer texts - const speechInput: SpeechInputSchema = { - elevenKey: elevenLabsApiKey, - text: text.slice(0, 1000), - voiceId: elevenLabsVoiceId, - nonEnglish, - ...(streaming && { streaming: true, streamOptimization: 4 }), - }; - - const response = await frontendSideFetch('/api/elevenlabs/speech', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(speechInput), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || errorData.message || 'Unknown error'); - } - - return response; -} \ No newline at end of file diff --git a/src/modules/tts/tts.client.hooks.ts b/src/modules/tts/tts.client.hooks.ts new file mode 100644 index 0000000000..55c6857296 --- /dev/null +++ b/src/modules/tts/tts.client.hooks.ts @@ -0,0 +1,11 @@ +import { getTTSEngine } from './useTTSStore'; +import { findTTSVendor } from './vendors/vendors.registry'; + +export function useTTSCapability() { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor) { + throw new Error(`No TTS Engine found for ${TTSEngine}`); + } + return vendor.getCapabilityInfo(); +} diff --git a/src/modules/tts/tts.client.ts b/src/modules/tts/tts.client.ts new file mode 100644 index 0000000000..c96a09ecad --- /dev/null +++ b/src/modules/tts/tts.client.ts @@ -0,0 +1,41 @@ +import { getTTSEngine } from './useTTSStore'; +import { findTTSVendor } from './vendors/vendors.registry'; + +export async function speakText(text: string, voiceId?: string) { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor) { + throw new Error(`No TTS Engine found for ${TTSEngine}`); + } + return vendor.speakText(text, voiceId); +} + +export async function EXPERIMENTAL_speakTextStream(text: string, voiceId?: string) { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor) { + throw new Error(`No TTS Engine found for ${TTSEngine}`); + } + return vendor.EXPERIMENTAL_speakTextStream(text, voiceId); +} + +export function cancel() { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor) { + throw new Error(`No TTS Engine found for ${TTSEngine}`); + } + if (!vendor.cancel) { + return; + } + return vendor.cancel(); +} + +export function getName() { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor) { + throw new Error(`No TTS Engine found for ${TTSEngine}`); + } + return vendor.name; +} \ No newline at end of file diff --git a/src/modules/tts/tts.setting.tsx b/src/modules/tts/tts.setting.tsx new file mode 100644 index 0000000000..3bcb26d14e --- /dev/null +++ b/src/modules/tts/tts.setting.tsx @@ -0,0 +1,11 @@ +import { getTTSEngine } from './useTTSStore'; +import { findTTSVendor } from './vendors/vendors.registry'; + +export function TTSSetting() { + const TTSEngine = getTTSEngine(); + const vendor = findTTSVendor(TTSEngine); + if (!vendor || !vendor.TTSSettingsComponent) { + return <>; + } + return ; +} diff --git a/src/modules/tts/useTTSStore.ts b/src/modules/tts/useTTSStore.ts new file mode 100644 index 0000000000..6100a65c0c --- /dev/null +++ b/src/modules/tts/useTTSStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { useShallow } from 'zustand/react/shallow'; + +export type TTSEngineKey = 'elevenlabs' | 'webspeech'; + +export const TTSEngineList: { key: TTSEngineKey; label: string }[] = [ + { + key: 'elevenlabs', + label: 'ElevenLabs', + }, + { + key: 'webspeech', + label: 'Web Speech API', + }, +]; + +interface TTSStore { + TTSEngine: TTSEngineKey; + setTTSEngine: (TTSEngine: TTSEngineKey) => void; +} + +const useTTSStore = create()( + persist( + (_set, _get) => ({ + TTSEngine: TTSEngineList[0].key, + setTTSEngine: (TTSEngine: TTSEngineKey) => _set({ TTSEngine }), + }), + { name: 'tts' }, + ), +); + +export const useTTSEngine = (): [TTSEngineKey, (TTSEngine: TTSEngineKey) => void] => useTTSStore(useShallow((state) => [state.TTSEngine, state.setTTSEngine])); +export const getTTSEngine = () => useTTSStore.getState().TTSEngine; diff --git a/src/modules/tts/vendors/ISpeechSynthesis.ts b/src/modules/tts/vendors/ISpeechSynthesis.ts new file mode 100644 index 0000000000..8a40fca41e --- /dev/null +++ b/src/modules/tts/vendors/ISpeechSynthesis.ts @@ -0,0 +1,30 @@ +import type React from 'react'; + +import type { SvgIconProps } from '@mui/joy'; +import { TTSEngineKey } from './vendors.registry'; + +export interface ISpeechSynthesis<> { + readonly id: TTSEngineKey; + readonly name: string; + readonly location: 'local' | 'cloud'; + + // components + // readonly Icon: React.FunctionComponent; + readonly TTSSettingsComponent?: React.ComponentType; + + /// abstraction interface /// + + hasVoices?(): boolean; + getCapabilityInfo(): CapabilitySpeechSynthesis; + speakText(text: string, voiceId?: string): Promise; + EXPERIMENTAL_speakTextStream(text: string, voiceId?: string): Promise; + cancel?(): Promise; + stop?(): Promise; + resume?(): Promise; +} + +export interface CapabilitySpeechSynthesis { + mayWork: boolean; + isConfiguredServerSide: boolean; + isConfiguredClientSide: boolean; +} diff --git a/src/modules/elevenlabs/ElevenlabsSettings.tsx b/src/modules/tts/vendors/elevenlabs/ElevenlabsSettings.tsx similarity index 86% rename from src/modules/elevenlabs/ElevenlabsSettings.tsx rename to src/modules/tts/vendors/elevenlabs/ElevenlabsSettings.tsx index 51b07db941..5a93fc12f2 100644 --- a/src/modules/elevenlabs/ElevenlabsSettings.tsx +++ b/src/modules/tts/vendors/elevenlabs/ElevenlabsSettings.tsx @@ -5,9 +5,8 @@ import { FormControl } from '@mui/joy'; import { AlreadySet } from '~/common/components/AlreadySet'; import { FormInputKey } from '~/common/components/forms/FormInputKey'; import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; -import { useCapabilityElevenLabs } from '~/common/components/useCapabilities'; -import { isElevenLabsEnabled } from './elevenlabs.client'; +import { elevenlabs, isElevenLabsEnabled } from './elevenlabs.vendor'; import { useElevenLabsVoiceDropdown } from './useElevenLabsVoiceDropdown'; import { useElevenLabsApiKey } from './store-module-elevenlabs'; @@ -16,7 +15,7 @@ export function ElevenlabsSettings() { // external state const [apiKey, setApiKey] = useElevenLabsApiKey(); - const { isConfiguredServerSide } = useCapabilityElevenLabs(); + const { isConfiguredServerSide } = elevenlabs.getCapabilityInfo(); const { voicesDropdown } = useElevenLabsVoiceDropdown(true); diff --git a/src/modules/elevenlabs/elevenlabs.router.ts b/src/modules/tts/vendors/elevenlabs/elevenlabs.router.ts similarity index 100% rename from src/modules/elevenlabs/elevenlabs.router.ts rename to src/modules/tts/vendors/elevenlabs/elevenlabs.router.ts diff --git a/src/modules/elevenlabs/elevenlabs.server.ts b/src/modules/tts/vendors/elevenlabs/elevenlabs.server.ts similarity index 100% rename from src/modules/elevenlabs/elevenlabs.server.ts rename to src/modules/tts/vendors/elevenlabs/elevenlabs.server.ts diff --git a/src/modules/tts/vendors/elevenlabs/elevenlabs.vendor.ts b/src/modules/tts/vendors/elevenlabs/elevenlabs.vendor.ts new file mode 100644 index 0000000000..46b1958e30 --- /dev/null +++ b/src/modules/tts/vendors/elevenlabs/elevenlabs.vendor.ts @@ -0,0 +1,107 @@ +import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities'; + +import { AudioLivePlayer } from '~/common/util/audio/AudioLivePlayer'; +import { AudioPlayer } from '~/common/util/audio/AudioPlayer'; +import { frontendSideFetch } from '~/common/util/clientFetchers'; +import { useUIPreferencesStore } from '~/common/state/store-ui'; + +import type { SpeechInputSchema } from './elevenlabs.router'; +import { getElevenLabsData, useElevenLabsData } from './store-module-elevenlabs'; +import { ElevenlabsSettings } from './ElevenlabsSettings'; +import { CapabilitySpeechSynthesis, ISpeechSynthesis } from '../ISpeechSynthesis'; + +const isValidElevenLabsApiKey = (apiKey?: string) => !!apiKey && apiKey.trim()?.length >= 32; + +export const isElevenLabsEnabled = (apiKey?: string) => (apiKey ? isValidElevenLabsApiKey(apiKey) : getBackendCapabilities().hasVoiceElevenLabs); + +/** + * Note: we have to use this client-side API instead of TRPC because of ArrayBuffers.. + */ +async function frontendFetchAPIElevenLabsSpeech( + text: string, + elevenLabsApiKey: string, + elevenLabsVoiceId: string, + nonEnglish: boolean, + streaming: boolean, +): Promise { + // NOTE: hardcoded 1000 as a failsafe, since the API will take very long and consume lots of credits for longer texts + const speechInput: SpeechInputSchema = { + elevenKey: elevenLabsApiKey, + text: text.slice(0, 1000), + voiceId: elevenLabsVoiceId, + nonEnglish, + ...(streaming && { streaming: true, streamOptimization: 4 }), + }; + + const response = await frontendSideFetch('/api/elevenlabs/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(speechInput), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || errorData.message || 'Unknown error'); + } + + return response; +} + +export const elevenlabs: ISpeechSynthesis = { + id: 'webspeech', + name: 'Web Speech API', + location: 'cloud', + + // components + TTSSettingsComponent: ElevenlabsSettings, + + // functions + getCapabilityInfo(): CapabilitySpeechSynthesis { + const {elevenLabsApiKey:clientApiKey, elevenLabsVoiceId:voiceId} = getElevenLabsData(); + const isConfiguredServerSide = getBackendCapabilities().hasVoiceElevenLabs; + const isConfiguredClientSide = clientApiKey ? isValidElevenLabsApiKey(clientApiKey) : false; + const mayWork = isConfiguredServerSide || isConfiguredClientSide || !!voiceId; + return { mayWork, isConfiguredServerSide, isConfiguredClientSide }; + }, + + async speakText(text: string, voiceId?: string) { + if (!text?.trim()) return; + + const { elevenLabsApiKey, elevenLabsVoiceId } = getElevenLabsData(); + if (!isElevenLabsEnabled(elevenLabsApiKey)) return; + + const { preferredLanguage } = useUIPreferencesStore.getState(); + const nonEnglish = !preferredLanguage?.toLowerCase()?.startsWith('en'); + + try { + const edgeResponse = await frontendFetchAPIElevenLabsSpeech(text, elevenLabsApiKey, voiceId || elevenLabsVoiceId, nonEnglish, false); + const audioBuffer = await edgeResponse.arrayBuffer(); + await AudioPlayer.playBuffer(audioBuffer); + } catch (error) { + console.error('Error playing first text:', error); + } + }, + + // let liveAudioPlayer: LiveAudioPlayer | undefined = undefined; + async EXPERIMENTAL_speakTextStream(text: string, voiceId?: string) { + if (!text?.trim()) return; + + const { elevenLabsApiKey, elevenLabsVoiceId } = getElevenLabsData(); + if (!isElevenLabsEnabled(elevenLabsApiKey)) return; + + const { preferredLanguage } = useUIPreferencesStore.getState(); + const nonEnglish = !preferredLanguage?.toLowerCase()?.startsWith('en'); + + try { + const edgeResponse = await frontendFetchAPIElevenLabsSpeech(text, elevenLabsApiKey, voiceId || elevenLabsVoiceId, nonEnglish, true); + + // if (!liveAudioPlayer) + const liveAudioPlayer = new AudioLivePlayer(); + // fire/forget + void liveAudioPlayer.EXPERIMENTAL_playStream(edgeResponse); + } catch (error) { + // has happened once in months of testing, not sure what was the cause + console.error('EXPERIMENTAL_speakTextStream:', error); + } + }, +}; diff --git a/src/modules/elevenlabs/store-module-elevenlabs.ts b/src/modules/tts/vendors/elevenlabs/store-module-elevenlabs.ts similarity index 100% rename from src/modules/elevenlabs/store-module-elevenlabs.ts rename to src/modules/tts/vendors/elevenlabs/store-module-elevenlabs.ts diff --git a/src/modules/elevenlabs/useElevenLabsVoiceDropdown.tsx b/src/modules/tts/vendors/elevenlabs/useElevenLabsVoiceDropdown.tsx similarity index 96% rename from src/modules/elevenlabs/useElevenLabsVoiceDropdown.tsx rename to src/modules/tts/vendors/elevenlabs/useElevenLabsVoiceDropdown.tsx index fdfaafe3a2..9b2bb0fa4f 100644 --- a/src/modules/elevenlabs/useElevenLabsVoiceDropdown.tsx +++ b/src/modules/tts/vendors/elevenlabs/useElevenLabsVoiceDropdown.tsx @@ -8,7 +8,7 @@ import { AudioPlayer } from '~/common/util/audio/AudioPlayer'; import { apiQuery } from '~/common/util/trpc.client'; import { VoiceSchema } from './elevenlabs.router'; -import { isElevenLabsEnabled } from './elevenlabs.client'; +import { isElevenLabsEnabled } from './elevenlabs.vendor'; import { useElevenLabsApiKey, useElevenLabsVoiceId } from './store-module-elevenlabs'; @@ -82,6 +82,10 @@ export function useElevenLabsVoiceDropdown(autoSpeak: boolean, disabled?: boolea React.useEffect(() => { if (previewUrl) void AudioPlayer.playUrl(previewUrl); + + return () => { + // TODO: stop audio + } }, [previewUrl]); const voicesDropdown = React.useMemo(() => diff --git a/src/modules/tts/vendors/vendors.registry.ts b/src/modules/tts/vendors/vendors.registry.ts new file mode 100644 index 0000000000..75319650fa --- /dev/null +++ b/src/modules/tts/vendors/vendors.registry.ts @@ -0,0 +1,19 @@ +import { TTSEngineKey } from '../useTTSStore'; +import { elevenlabs } from './elevenlabs/elevenlabs.vendor'; +import { ISpeechSynthesis } from './ISpeechSynthesis'; +import { webspeech } from './webspeech/webspeech.vendor'; + +/** Global: Vendor Instances Registry **/ +const MODEL_VENDOR_REGISTRY: Record = { + elevenlabs:elevenlabs, + webspeech:webspeech, +} as Record; + +export function findAllTTSVendors(): ISpeechSynthesis[] { + const modelVendors = Object.values(MODEL_VENDOR_REGISTRY); + return modelVendors; +} + +export function findTTSVendor(TTSEngineKey?: TTSEngineKey): ISpeechSynthesis | null { + return TTSEngineKey ? ((MODEL_VENDOR_REGISTRY[TTSEngineKey] as ISpeechSynthesis) ?? null) : null; +} diff --git a/src/modules/tts/vendors/webspeech/WebspeechSettings.tsx b/src/modules/tts/vendors/webspeech/WebspeechSettings.tsx new file mode 100644 index 0000000000..d4c4cf8013 --- /dev/null +++ b/src/modules/tts/vendors/webspeech/WebspeechSettings.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import { Option, FormControl, Select, Switch, Typography, Box, IconButton } from '@mui/joy'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import CloseRounded from '@mui/icons-material/CloseRounded'; +import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore'; + +import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; +import { useBrowserSpeechVoiceDropdown } from './useWebspeechVoiceDropdown'; +import { useLanguageCodeForFilter } from './store-module-webspeech'; + +// languages are defined as a JSON file +import languages from './preSelect/Languages.json'; + +export function WebspeechSettings() { + // state + const [testUtterance, setTestUtterance] = React.useState(null); + const [voiceNameFilters, setVoiceNameFilters] = React.useState(null); + + // external state + const [languageCode, setLanguageCode] = useLanguageCodeForFilter(); + + React.useEffect(() => { + if (languageCode) { + const fetchFunction = async () => { + let res = await fetch(`https://raw.githubusercontent.com/HadrienGardeur/web-speech-recommended-voices/refs/heads/main/json/${languageCode}.json`); + let data = await res.json(); + let voices = data.voices; + voices = voices.filter((voice: any) => { + return voice.quality.includes('high') || voice.quality.includes('veryHigh'); + }); + let voiceNameFilters = voices.map((voice: any) => voice.name); + setTestUtterance(data.testUtterance); + setVoiceNameFilters(voiceNameFilters); + }; + fetchFunction().catch((err) => { + console.log('Error getting voice list: ', err); + addSnackbar({ key: 'browser-speech-synthesis', message: 'Error getting voice list', type: 'issue' }); + setTestUtterance(null); + setVoiceNameFilters(null); + setLanguageCode(''); + }); + } else { + setTestUtterance(null); + setVoiceNameFilters(null); + } + }, [languageCode, setLanguageCode]); + + const { voicesDropdown } = useBrowserSpeechVoiceDropdown(true, { voiceNameFilters, testUtterance }); + + const languageOptions = React.useMemo(() => { + return Object.entries(languages) + .sort((a, b) => { + return a[1].localeCompare(b[1]); + }) + .map(([languageCode, languageName]) => ( + + )); + }, []); + + function handleLanguageChanged(_event: any, newValue: string | null) { + setLanguageCode(newValue || ''); + } + + return ( + <> + + + + + + + {voicesDropdown} + + + ); +} diff --git a/src/modules/tts/vendors/webspeech/preSelect/Languages.json b/src/modules/tts/vendors/webspeech/preSelect/Languages.json new file mode 100644 index 0000000000..69c3edec8d --- /dev/null +++ b/src/modules/tts/vendors/webspeech/preSelect/Languages.json @@ -0,0 +1,44 @@ +{ + "ar": "Arabic", + "bho": "Bhojpuri", + "bn": "Bangla", + "ca": "Catalan", + "cmn": "Chinese", + "cs": "Czech", + "da": "Danish", + "de": "German", + "el": "Greek", + "en": "English", + "es": "Spanish", + "eu": "Basque", + "fa": "Persian", + "fi": "Finnish", + "fr": "French", + "gl": "Galician", + "he": "Hebrew", + "hi": "Hindi", + "hr": "Croatian", + "hu": "Hungarian", + "id": "Indonesian", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "mr": "Marathi", + "ms": "Malay", + "nb": "Norwegian Bokmål", + "nl": "Dutch", + "pl": "Polish", + "pt": "Portuguese", + "ro": "Romanian", + "ru": "Russian", + "sk": "Slovak", + "sl": "Slovenian", + "sv": "Swedish", + "ta": "Tamil", + "te": "Telugu", + "th": "Thai", + "tr": "Turkish", + "uk": "Ukrainian", + "vi": "Vietnamese", + "wuu": "Shanghainese" +} \ No newline at end of file diff --git a/src/modules/tts/vendors/webspeech/store-module-webspeech.ts b/src/modules/tts/vendors/webspeech/store-module-webspeech.ts new file mode 100644 index 0000000000..434c9c359a --- /dev/null +++ b/src/modules/tts/vendors/webspeech/store-module-webspeech.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { useShallow } from 'zustand/react/shallow'; + +export type BrowsePageTransform = 'html' | 'text' | 'markdown'; + +interface BrowseState { + + languageCodeForFilter: string; + browseVoiceId: string; + setBrowseVoiceId: (value: string) => void; + setLanguageCodeForFilter: (value: string) => void; + +} + +export const useBrowseStore = create()( + persist( + (set) => ({ + languageCodeForFilter: '', + browseVoiceId: '', + setBrowseVoiceId: (browseVoiceId: string) => set(() => ({ browseVoiceId })), + setLanguageCodeForFilter: (languageCodeForFilter: string) => set(() => ({ languageCodeForFilter })), + }), + { + name: 'app-module-browse', + }, + ), +); + +export function useBrowseVoiceId(): [string, (value: string) => void] { + return useBrowseStore(useShallow(state => [state.browseVoiceId, state.setBrowseVoiceId])) +} + +export function useLanguageCodeForFilter(): [string, (value: string) => void] { + return useBrowseStore(useShallow(state => [state.languageCodeForFilter, state.setLanguageCodeForFilter])) +} + +export function getBrowseVoiceId() { + return useBrowseStore.getState().browseVoiceId +} \ No newline at end of file diff --git a/src/modules/tts/vendors/webspeech/useWebspeechVoiceDropdown.tsx b/src/modules/tts/vendors/webspeech/useWebspeechVoiceDropdown.tsx new file mode 100644 index 0000000000..c0ff014ea0 --- /dev/null +++ b/src/modules/tts/vendors/webspeech/useWebspeechVoiceDropdown.tsx @@ -0,0 +1,124 @@ +import * as React from 'react'; + +import { CircularProgress, Option, Select } from '@mui/joy'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone'; + +import { useBrowseVoiceId } from './store-module-webspeech'; +import { speakText, cancel } from '../../tts.client'; + +function VoicesDropdown(props: { + isValidKey: boolean; + isFetchingVoices: boolean; + isErrorVoices: boolean; + disabled?: boolean; + voices: SpeechSynthesisVoice[]; + voiceId: string; + setVoiceId: (voiceId: string) => void; +}) { + const handleVoiceChange = (_event: any, value: string | null) => props.setVoiceId(value === null ? '' : value); + + return ( + + ); +} + +function allVoicesObtained(): Promise { + return new Promise(function (resolve, reject) { + let voices = window.speechSynthesis.getVoices(); + if (voices.length !== 0) { + resolve(voices); + } else { + window.speechSynthesis.addEventListener('voiceschanged', function () { + voices = window.speechSynthesis.getVoices(); + resolve(voices); + }); + } + }); +} + +export function useBrowserSpeechVoices() { + const [voices, setVoices] = React.useState([]); + + React.useEffect(() => { + allVoicesObtained().then((data) => setVoices(data)); + }, []); + + return { + hasVoices: voices.length > 0, + voices: voices || [], + }; +} + +export function useBrowserSpeechVoiceDropdown( + autoSpeak: boolean, + { + disabled, + voiceNameFilters, + testUtterance, + }: { + disabled?: boolean; + voiceNameFilters?: string[] | null; + testUtterance?: string | null; + }, +) { + // external state + const { hasVoices, voices } = useBrowserSpeechVoices(); + const [voiceId, setVoiceId] = useBrowseVoiceId(); + + // derived state + const voice = voices.find((voice) => voiceId === voice.name); + const voiceFiltered = voiceNameFilters ? voices.filter((voice) => voiceNameFilters.includes(voice.name)) : voices; + + // [E] autoSpeak + React.useEffect(() => { + if (autoSpeak && voice && voiceFiltered.includes(voice)) { + speakText(testUtterance ? testUtterance.replace('{name}', voice.name) : `How can I assist you today?`, String(voiceId)); + } + return () => { + cancel(); + }; + }, [autoSpeak, testUtterance, voice, voiceFiltered, voiceId, voiceNameFilters]); + + const voicesDropdown = React.useMemo( + () => ( + + ), + [disabled, setVoiceId, voiceFiltered, voiceId], + ); + + return { + hasVoices, + voiceId, + voiceName: voice?.name, + voicesDropdown, + }; +} diff --git a/src/modules/tts/vendors/webspeech/webspeech.vendor.ts b/src/modules/tts/vendors/webspeech/webspeech.vendor.ts new file mode 100644 index 0000000000..9242ab50cf --- /dev/null +++ b/src/modules/tts/vendors/webspeech/webspeech.vendor.ts @@ -0,0 +1,65 @@ +import { getBrowseVoiceId } from './store-module-webspeech'; +import { CapabilitySpeechSynthesis, ISpeechSynthesis } from '../ISpeechSynthesis'; +import { WebspeechSettings } from './WebspeechSettings'; + +export const webspeech: ISpeechSynthesis = { + id: 'webspeech', + name: 'Web Speech API', + location: 'cloud', + + // components + TTSSettingsComponent: WebspeechSettings, + + // functions + + getCapabilityInfo(): CapabilitySpeechSynthesis { + const synth = window.speechSynthesis; + const voices = synth.getVoices(); + const isConfiguredServerSide = false; + const isConfiguredClientSide = true; + const mayWork = voices.length > 0; + return { mayWork, isConfiguredServerSide, isConfiguredClientSide }; + }, + + hasVoices() { + const synth = window.speechSynthesis; + const voices = synth.getVoices(); + return voices.length > 0; + }, + + async speakText(text: string, voiceId?: string) { + if (!text?.trim()) return; + + try { + const synth = window.speechSynthesis; + const utterThis = new SpeechSynthesisUtterance(text); + const voices = synth.getVoices(); + voiceId = voiceId || getBrowseVoiceId(); + utterThis.voice = voices.find((voice) => voiceId === voice.name) || null; + synth.speak(utterThis); + } catch (error) { + console.error('Error playing first text:', error); + } + }, + + async cancel() { + const synth = window.speechSynthesis; + synth.cancel(); + }, + + async EXPERIMENTAL_speakTextStream(text: string, voiceId?: string) { + if (!text?.trim()) return; + + try { + const synth = window.speechSynthesis; + const utterThis = new SpeechSynthesisUtterance(text); + const voices = synth.getVoices(); + voiceId = voiceId || getBrowseVoiceId(); + utterThis.voice = voices.find((voice) => voiceId === voice.name) || null; + synth.speak(utterThis); + } catch (error) { + // has happened once in months of testing, not sure what was the cause + console.error('EXPERIMENTAL_speakTextStream:', error); + } + }, +}; diff --git a/src/server/api/trpc.router-edge.ts b/src/server/api/trpc.router-edge.ts index 54fce437b9..e2d3fac67d 100644 --- a/src/server/api/trpc.router-edge.ts +++ b/src/server/api/trpc.router-edge.ts @@ -2,7 +2,7 @@ import { createTRPCRouter } from './trpc.server'; import { aixRouter } from '~/modules/aix/server/api/aix.router'; import { backendRouter } from '~/modules/backend/backend.router'; -import { elevenlabsRouter } from '~/modules/elevenlabs/elevenlabs.router'; +import { elevenlabsRouter } from '~/modules/tts/vendors/elevenlabs/elevenlabs.router'; import { googleSearchRouter } from '~/modules/google/search.router'; import { llmAnthropicRouter } from '~/modules/llms/server/anthropic/anthropic.router'; import { llmGeminiRouter } from '~/modules/llms/server/gemini/gemini.router';