| | import { useParams } from 'react-router-dom'; |
| | import { useEffect, useCallback } from 'react'; |
| | import { QueryKeys } from 'librechat-data-provider'; |
| | import { useQueryClient } from '@tanstack/react-query'; |
| | import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; |
| | import type { TMessage } from 'librechat-data-provider'; |
| | import { useCustomAudioRef, MediaSourceAppender, usePauseGlobalAudio } from '~/hooks/Audio'; |
| | import { getLatestText, logger } from '~/utils'; |
| | import { useAuthContext } from '~/hooks'; |
| | import { globalAudioId } from '~/common'; |
| | import store from '~/store'; |
| |
|
| | function timeoutPromise(ms: number, message?: string) { |
| | return new Promise((_, reject) => |
| | setTimeout(() => reject(new Error(message ?? 'Promise timed out')), ms), |
| | ); |
| | } |
| |
|
| | const promiseTimeoutMessage = 'Reader promise timed out'; |
| | const maxPromiseTime = 15000; |
| |
|
| | export default function StreamAudio({ index = 0 }) { |
| | const { token } = useAuthContext(); |
| |
|
| | const cacheTTS = useRecoilValue(store.cacheTTS); |
| | const playbackRate = useRecoilValue(store.playbackRate); |
| |
|
| | const voice = useRecoilValue(store.voice); |
| | const activeRunId = useRecoilValue(store.activeRunFamily(index)); |
| | const automaticPlayback = useRecoilValue(store.automaticPlayback); |
| | const isSubmitting = useRecoilValue(store.isSubmittingFamily(index)); |
| | const latestMessage = useRecoilValue(store.latestMessageFamily(index)); |
| | const setIsPlaying = useSetRecoilState(store.globalAudioPlayingFamily(index)); |
| | const [audioRunId, setAudioRunId] = useRecoilState(store.audioRunFamily(index)); |
| | const [isFetching, setIsFetching] = useRecoilState(store.globalAudioFetchingFamily(index)); |
| | const [globalAudioURL, setGlobalAudioURL] = useRecoilState(store.globalAudioURLFamily(index)); |
| |
|
| | const { audioRef } = useCustomAudioRef({ setIsPlaying }); |
| | const { pauseGlobalAudio } = usePauseGlobalAudio(); |
| |
|
| | const { conversationId: paramId } = useParams(); |
| | const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? ''; |
| |
|
| | const queryClient = useQueryClient(); |
| | const getMessages = useCallback( |
| | () => queryClient.getQueryData<TMessage[]>([QueryKeys.messages, queryParam]), |
| | [queryParam, queryClient], |
| | ); |
| |
|
| | useEffect(() => { |
| | const latestText = getLatestText(latestMessage); |
| |
|
| | const shouldFetch = !!( |
| | token != null && |
| | automaticPlayback && |
| | !isSubmitting && |
| | latestMessage && |
| | !latestMessage.isCreatedByUser && |
| | latestText && |
| | latestMessage.messageId && |
| | !latestMessage.messageId.includes('_') && |
| | !isFetching && |
| | activeRunId != null && |
| | activeRunId !== audioRunId |
| | ); |
| |
|
| | if (!shouldFetch) { |
| | return; |
| | } |
| |
|
| | async function fetchAudio() { |
| | setIsFetching(true); |
| |
|
| | try { |
| | if (audioRef.current) { |
| | audioRef.current.pause(); |
| | URL.revokeObjectURL(audioRef.current.src); |
| | setGlobalAudioURL(null); |
| | } |
| |
|
| | let cacheKey = latestMessage?.text ?? ''; |
| | const cache = await caches.open('tts-responses'); |
| | const cachedResponse = await cache.match(cacheKey); |
| |
|
| | setAudioRunId(activeRunId); |
| | if (cachedResponse) { |
| | logger.log('Audio found in cache'); |
| | const audioBlob = await cachedResponse.blob(); |
| | const blobUrl = URL.createObjectURL(audioBlob); |
| | setGlobalAudioURL(blobUrl); |
| | setIsFetching(false); |
| | return; |
| | } |
| |
|
| | logger.log('Fetching audio...', navigator.userAgent); |
| | const response = await fetch('/api/files/speech/tts', { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, |
| | body: JSON.stringify({ messageId: latestMessage?.messageId, runId: activeRunId, voice }), |
| | }); |
| |
|
| | if (!response.ok) { |
| | throw new Error('Failed to fetch audio'); |
| | } |
| | if (!response.body) { |
| | throw new Error('Null Response body'); |
| | } |
| |
|
| | const reader = response.body.getReader(); |
| |
|
| | const type = 'audio/mpeg'; |
| | const browserSupportsType = |
| | typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(type); |
| | let mediaSource: MediaSourceAppender | undefined; |
| | if (browserSupportsType) { |
| | mediaSource = new MediaSourceAppender(type); |
| | setGlobalAudioURL(mediaSource.mediaSourceUrl); |
| | } |
| |
|
| | let done = false; |
| | const chunks: ArrayBuffer[] = []; |
| |
|
| | while (!done) { |
| | const readPromise = reader.read(); |
| | const { value, done: readerDone } = (await Promise.race([ |
| | readPromise, |
| | timeoutPromise(maxPromiseTime, promiseTimeoutMessage), |
| | ])) as ReadableStreamReadResult<ArrayBuffer>; |
| |
|
| | if (cacheTTS && value) { |
| | chunks.push(value); |
| | } |
| | if (value && mediaSource) { |
| | mediaSource.addData(value); |
| | } |
| | done = readerDone; |
| | } |
| |
|
| | if (chunks.length) { |
| | logger.log('Adding audio to cache'); |
| | const latestMessages = getMessages() ?? []; |
| | const targetMessage = latestMessages.find( |
| | (msg) => msg.messageId === latestMessage?.messageId, |
| | ); |
| | cacheKey = targetMessage?.text ?? ''; |
| | if (!cacheKey) { |
| | throw new Error('Cache key not found'); |
| | } |
| | const audioBlob = new Blob(chunks, { type }); |
| | const cachedResponse = new Response(audioBlob); |
| | await cache.put(cacheKey, cachedResponse); |
| | if (!browserSupportsType) { |
| | const unconsumedResponse = await cache.match(cacheKey); |
| | if (!unconsumedResponse) { |
| | throw new Error('Failed to fetch audio from cache'); |
| | } |
| | const audioBlob = await unconsumedResponse.blob(); |
| | const blobUrl = URL.createObjectURL(audioBlob); |
| | setGlobalAudioURL(blobUrl); |
| | } |
| | setIsFetching(false); |
| | } |
| |
|
| | logger.log('Audio stream reading ended'); |
| | } catch (error) { |
| | if (error?.['message'] !== promiseTimeoutMessage) { |
| | logger.log(promiseTimeoutMessage); |
| | return; |
| | } |
| | logger.error('Error fetching audio:', error); |
| | setIsFetching(false); |
| | setGlobalAudioURL(null); |
| | } finally { |
| | setIsFetching(false); |
| | } |
| | } |
| |
|
| | fetchAudio(); |
| | }, [ |
| | automaticPlayback, |
| | setGlobalAudioURL, |
| | setAudioRunId, |
| | setIsFetching, |
| | latestMessage, |
| | isSubmitting, |
| | activeRunId, |
| | getMessages, |
| | isFetching, |
| | audioRunId, |
| | cacheTTS, |
| | audioRef, |
| | voice, |
| | token, |
| | ]); |
| |
|
| | useEffect(() => { |
| | if ( |
| | playbackRate != null && |
| | globalAudioURL != null && |
| | playbackRate > 0 && |
| | audioRef.current && |
| | audioRef.current.playbackRate !== playbackRate |
| | ) { |
| | audioRef.current.playbackRate = playbackRate; |
| | } |
| | }, [audioRef, globalAudioURL, playbackRate]); |
| |
|
| | useEffect(() => { |
| | pauseGlobalAudio(); |
| | |
| | |
| | }, [paramId]); |
| |
|
| | logger.log('StreamAudio.tsx - globalAudioURL:', globalAudioURL); |
| | return ( |
| | |
| | <audio |
| | ref={audioRef} |
| | controls |
| | controlsList="nodownload nofullscreen noremoteplayback" |
| | style={{ |
| | position: 'absolute', |
| | overflow: 'hidden', |
| | display: 'none', |
| | height: '0px', |
| | width: '0px', |
| | }} |
| | src={globalAudioURL ?? undefined} |
| | id={globalAudioId} |
| | autoPlay |
| | /> |
| | ); |
| | } |
| |
|