| const axios = require('axios'); |
| const getCustomConfig = require('~/server/services/Config/getCustomConfig'); |
| const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); |
| const { extractEnvVariable } = require('librechat-data-provider'); |
| const { logger } = require('~/config'); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function getProvider(ttsSchema) { |
| if (!ttsSchema) { |
| throw new Error(`No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)? |
| |
| https://www.librechat.ai/docs/configuration/stt_tts#tts`); |
| } |
| const providers = Object.entries(ttsSchema).filter(([, value]) => Object.keys(value).length > 0); |
|
|
| if (providers.length > 1) { |
| throw new Error('Multiple providers are set. Please set only one provider.'); |
| } else if (providers.length === 0) { |
| throw new Error('No provider is set. Please set a provider.'); |
| } else { |
| return providers[0][0]; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function removeUndefined(obj) { |
| Object.keys(obj).forEach((key) => { |
| if (obj[key] && typeof obj[key] === 'object') { |
| removeUndefined(obj[key]); |
| if (Object.keys(obj[key]).length === 0) { |
| delete obj[key]; |
| } |
| } else if (obj[key] === undefined) { |
| delete obj[key]; |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function openAIProvider(ttsSchema, input, voice) { |
| const url = ttsSchema?.url || 'https://api.openai.com/v1/audio/speech'; |
|
|
| if ( |
| ttsSchema?.voices && |
| ttsSchema.voices.length > 0 && |
| !ttsSchema.voices.includes(voice) && |
| !ttsSchema.voices.includes('ALL') |
| ) { |
| throw new Error(`Voice ${voice} is not available.`); |
| } |
|
|
| let data = { |
| input, |
| model: ttsSchema?.model, |
| voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined, |
| backend: ttsSchema?.backend, |
| }; |
|
|
| let headers = { |
| 'Content-Type': 'application/json', |
| Authorization: 'Bearer ' + extractEnvVariable(ttsSchema?.apiKey), |
| }; |
|
|
| [data, headers].forEach(removeUndefined); |
|
|
| return [url, data, headers]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function elevenLabsProvider(ttsSchema, input, voice, stream) { |
| let url = |
| ttsSchema?.url || |
| `https://api.elevenlabs.io/v1/text-to-speech/{voice_id}${stream ? '/stream' : ''}`; |
|
|
| if (!ttsSchema?.voices.includes(voice) && !ttsSchema?.voices.includes('ALL')) { |
| throw new Error(`Voice ${voice} is not available.`); |
| } |
|
|
| url = url.replace('{voice_id}', voice); |
|
|
| let data = { |
| model_id: ttsSchema?.model, |
| text: input, |
| |
| voice_settings: { |
| similarity_boost: ttsSchema?.voice_settings?.similarity_boost, |
| stability: ttsSchema?.voice_settings?.stability, |
| style: ttsSchema?.voice_settings?.style, |
| use_speaker_boost: ttsSchema?.voice_settings?.use_speaker_boost || undefined, |
| }, |
| pronunciation_dictionary_locators: ttsSchema?.pronunciation_dictionary_locators, |
| }; |
|
|
| let headers = { |
| 'Content-Type': 'application/json', |
| 'xi-api-key': extractEnvVariable(ttsSchema?.apiKey), |
| Accept: 'audio/mpeg', |
| }; |
|
|
| [data, headers].forEach(removeUndefined); |
|
|
| return [url, data, headers]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function localAIProvider(ttsSchema, input, voice) { |
| let url = ttsSchema?.url; |
|
|
| if ( |
| ttsSchema?.voices && |
| ttsSchema.voices.length > 0 && |
| !ttsSchema.voices.includes(voice) && |
| !ttsSchema.voices.includes('ALL') |
| ) { |
| throw new Error(`Voice ${voice} is not available.`); |
| } |
|
|
| let data = { |
| input, |
| model: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined, |
| backend: ttsSchema?.backend, |
| }; |
|
|
| let headers = { |
| 'Content-Type': 'application/json', |
| Authorization: 'Bearer ' + extractEnvVariable(ttsSchema?.apiKey), |
| }; |
|
|
| [data, headers].forEach(removeUndefined); |
|
|
| if (extractEnvVariable(ttsSchema.apiKey) === '') { |
| delete headers.Authorization; |
| } |
|
|
| return [url, data, headers]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function getProviderSchema(customConfig) { |
| const provider = getProvider(customConfig.tts); |
| return [provider, customConfig.tts[provider]]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function getVoice(providerSchema, requestVoice) { |
| const voices = providerSchema.voices.filter((voice) => voice && voice.toUpperCase() !== 'ALL'); |
| let voice = requestVoice; |
| if (!voice || !voices.includes(voice) || (voice.toUpperCase() === 'ALL' && voices.length > 1)) { |
| voice = getRandomVoiceId(voices); |
| } |
|
|
| return voice; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function ttsRequest(provider, ttsSchema, { input, voice, stream = true } = { stream: true }) { |
| let [url, data, headers] = []; |
| switch (provider) { |
| case 'openai': |
| [url, data, headers] = openAIProvider(ttsSchema, input, voice); |
| break; |
| case 'elevenlabs': |
| [url, data, headers] = elevenLabsProvider(ttsSchema, input, voice, stream); |
| break; |
| case 'localai': |
| [url, data, headers] = localAIProvider(ttsSchema, input, voice); |
| break; |
| default: |
| throw new Error('Invalid provider'); |
| } |
|
|
| if (stream) { |
| return await axios.post(url, data, { headers, responseType: 'stream' }); |
| } |
|
|
| return await axios.post(url, data, { headers, responseType: 'arraybuffer' }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function textToSpeech(req, res) { |
| const { input } = req.body; |
|
|
| if (!input) { |
| return res.status(400).send('Missing text in request body'); |
| } |
|
|
| const customConfig = await getCustomConfig(); |
| if (!customConfig) { |
| res.status(500).send('Custom config not found'); |
| } |
|
|
| try { |
| res.setHeader('Content-Type', 'audio/mpeg'); |
| const [provider, ttsSchema] = await getProviderSchema(customConfig); |
| const voice = await getVoice(ttsSchema, req.body.voice); |
| if (input.length < 4096) { |
| const response = await ttsRequest(provider, ttsSchema, { input, voice }); |
| response.data.pipe(res); |
| return; |
| } |
|
|
| const textChunks = splitTextIntoChunks(input, 1000); |
|
|
| for (const chunk of textChunks) { |
| try { |
| const response = await ttsRequest(provider, ttsSchema, { |
| voice, |
| input: chunk.text, |
| stream: true, |
| }); |
|
|
| logger.debug(`[textToSpeech] user: ${req?.user?.id} | writing audio stream`); |
| await new Promise((resolve) => { |
| response.data.pipe(res, { end: chunk.isFinished }); |
| response.data.on('end', () => { |
| resolve(); |
| }); |
| }); |
|
|
| if (chunk.isFinished) { |
| break; |
| } |
| } catch (innerError) { |
| logger.error('Error processing manual update:', chunk, innerError); |
| if (!res.headersSent) { |
| res.status(500).end(); |
| } |
| return; |
| } |
| } |
|
|
| if (!res.headersSent) { |
| res.end(); |
| } |
| } catch (error) { |
| logger.error( |
| 'Error creating the audio stream. Suggestion: check your provider quota. Error:', |
| error, |
| ); |
| res.status(500).send('An error occurred'); |
| } |
| } |
|
|
| async function streamAudio(req, res) { |
| res.setHeader('Content-Type', 'audio/mpeg'); |
| const customConfig = await getCustomConfig(); |
| if (!customConfig) { |
| return res.status(500).send('Custom config not found'); |
| } |
|
|
| const [provider, ttsSchema] = await getProviderSchema(customConfig); |
| const voice = await getVoice(ttsSchema, req.body.voice); |
|
|
| try { |
| let shouldContinue = true; |
|
|
| req.on('close', () => { |
| logger.warn('[streamAudio] Audio Stream Request closed by client'); |
| shouldContinue = false; |
| }); |
|
|
| const processChunks = createChunkProcessor(req.body.messageId); |
|
|
| while (shouldContinue) { |
| |
| |
| |
| |
| |
| |
|
|
| const updates = await processChunks(); |
| if (typeof updates === 'string') { |
| logger.error(`Error processing audio stream updates: ${JSON.stringify(updates)}`); |
| res.status(500).end(); |
| return; |
| } |
|
|
| if (updates.length === 0) { |
| await new Promise((resolve) => setTimeout(resolve, 1250)); |
| continue; |
| } |
|
|
| for (const update of updates) { |
| try { |
| const response = await ttsRequest(provider, ttsSchema, { |
| voice, |
| input: update.text, |
| stream: true, |
| }); |
|
|
| if (!shouldContinue) { |
| break; |
| } |
|
|
| logger.debug(`[streamAudio] user: ${req?.user?.id} | writing audio stream`); |
| await new Promise((resolve) => { |
| response.data.pipe(res, { end: update.isFinished }); |
| response.data.on('end', () => { |
| resolve(); |
| }); |
| }); |
|
|
| if (update.isFinished) { |
| shouldContinue = false; |
| break; |
| } |
| } catch (innerError) { |
| logger.error('Error processing update:', update, innerError); |
| if (!res.headersSent) { |
| res.status(500).end(); |
| } |
| return; |
| } |
| } |
|
|
| if (!shouldContinue) { |
| break; |
| } |
| } |
|
|
| if (!res.headersSent) { |
| res.end(); |
| } |
| } catch (error) { |
| logger.error('Failed to fetch audio:', error); |
| if (!res.headersSent) { |
| res.status(500).end(); |
| } |
| } |
| } |
|
|
| module.exports = { |
| textToSpeech, |
| getProvider, |
| streamAudio, |
| }; |
|
|