Аудиоплеер хук для вашего приложения React
В этом посте я расскажу о хуке AudioPlayer для управления упорядоченным воспроизведением аудиофрагментов во всем приложении.
Проблема
В одном из примеров, для сохранения порядка аудиофрагментов я поддерживал классическую структуру данных очереди внутри своего хука.
Вот код для быстрого ознакомления:
import { useState, useEffect } from "react";
const useAudioPlayer = () => {
// We are managing promises of audio urls instead of directly storing strings
// because there is no guarantee when openai tts api finishes processing and resolves a specific url
// For more info, check this comment:
// https://github.com/tarasglek/chatcraft.org/pull/357#discussion_r1473470003
const [queue, setQueue] = useState<Promise<string>[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
useEffect(() => {
if (!isPlaying && queue.length > 0) {
playAudio(queue[0]);
}
}, [queue, isPlaying]);
const playAudio = async (audioClipUri: Promise<string>) => {
setIsPlaying(true);
const audioUrl: string = await audioClipUri;
const audio = new Audio(audioUrl);
audio.onended = () => {
URL.revokeObjectURL(audioUrl); // To avoid memory leaks
setQueue((oldQueue) => oldQueue.slice(1));
setIsPlaying(false);
};
audio.play();
};
const addToAudioQueue = (audioClipUri: Promise<string>) => {
setQueue((oldQueue) => [...oldQueue, audioClipUri]);
};
return { addToAudioQueue };
};
export default useAudioPlayer;
Теперь это работает как Charm, когда вы используете этот хук только в одном месте приложения. Но представьте, что произойдет, если я буду использовать этот хук и из какой-то другой части приложения.
const { addToAudioQueue } = useAudioPlayer();
const audioClipUri = textToSpeech(ttsWordsBuffer);
addToAudioQueue(audioClipUri);
- Будет ли он по-прежнему поддерживать порядок?
- Оставить порядок в стороне,но есть ли гарантия, что в определенный момент времени воспроизводится только один аудиоклип?
Ответ, как вы уже догадались, НЕТ.
Причина в том, что каждый раз, когда мы вызываем перехватчик useAudioPlayer
, он возвращает новый экземпляр аудио очереди. Допустим, вы используете его в трех разных местах своего приложения, теперь у вас одновременно активны 3 аудиоплеера.
И если вы запихнете аудиоклипы на один из них, в то время как другой уже что-то играет, вы попадете в аналогичную ситуацию, как если бы у вас в комнате было 3 динамика, и каждый из них воспроизводил свою песню.
Реализация глобальной аудио очереди
Чтобы исправить эту проблему, мне пришлось убедиться, что во всем приложении всегда существует только один экземпляр аудио очереди, в то время как основная логика и интерфейс остаются прежними.
Это необходимо было сделать в следующих шагах.
Определение Audio Player Context
Первым шагом было определение React Context, который предоставил бы соответствующие функции для работы с аудиоочередью.
type AudioPlayerContextType = {
addToAudioQueue: (audioClipUri: Promise<string>) => void;
clearAudioQueue: () => void;
};
const AudioPlayerContext = createContext<AudioPlayerContextType>({
addToAudioQueue: () => {},
clearAudioQueue: () => {},
});
Обратите внимание, что я также представляю новый метод, называемый clearAudioQueue
. Скоро посмотрим реализацию.
Обертывание очереди аудио в Context Provider
Чтобы любой из компонентов приложения React мог читать из контекста, его сначала необходимо предоставить одному из его предков.
Это делается с помощью React Context Provider. Я обернул существующую логику аудиоплеера в функцию, которая возвращает AudioPlayerContext
Provider, предоставляющего функции, которые будут использоваться для работы с одной аудиоочередью в приложении.
import { useState, useEffect, createContext, useContext, ReactNode, FC } from "react";
type AudioClip = {
audioUrl: string;
audioElement: HTMLAudioElement;
};
export const AudioPlayerProvider: FC<{ children: ReactNode }> = ({ children }) => {
// We are managing promises of audio urls instead of directly storing strings
// because there is no guarantee when openai tts api finishes processing and resolves a specific url
// For more info, check this comment:
// https://github.com/tarasglek/chatcraft.org/pull/357#discussion_r1473470003
const [queue, setQueue] = useState<Promise<string>[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentAudioClip, setCurrentAudioClip] = useState<AudioClip | null>();
useEffect(() => {
if (!isPlaying && queue.length > 0) {
playAudio(queue[0]);
}
}, [queue, isPlaying]);
const playAudio = async (audioClipUri: Promise<string>) => {
setIsPlaying(true);
const audioUrl: string = await audioClipUri;
const audio = new Audio(audioUrl);
audio.preload = "auto";
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
setQueue((oldQueue) => oldQueue.slice(1));
setIsPlaying(false);
setCurrentAudioClip(null);
};
audio.play();
setCurrentAudioClip({
audioElement: audio,
audioUrl: audioUrl,
});
};
const addToAudioQueue = (audioClipUri: Promise<string>) => {
setQueue((oldQueue) => [...oldQueue, audioClipUri]);
};
const clearAudioQueue = () => {
if (currentAudioClip) {
// Stop currently playing audio
currentAudioClip.audioElement.pause();
URL.revokeObjectURL(currentAudioClip.audioUrl);
setCurrentAudioClip(null);
setIsPlaying(false);
}
// Flush all the remaining audio clips
setQueue([]);
};
const value = { addToAudioQueue, clearAudioQueue };
return <AudioPlayerContext.Provider value={value}>{children}</AudioPlayerContext.Provider>;
};
Вы заметите реализацию функции clearAudioQueue
, которая предназначена для вызова из любого компонента приложения для приостановки текущего аудиоклипа и очистки оставшихся клипов в очереди.
Я также сейчас управляю новым состоянием:
type AudioClip = {
audioUrl: string;
audioElement: HTMLAudioElement;
};
const [currentAudioClip, setCurrentAudioClip] = useState<AudioClip | null>();
Это позволяет мне pause
текущий звук сразу после вызова функции clearAudioQueue
.
Предоставление контекста
Следующим шагом будет предоставление файла AudioPlayerContext
в корне приложения.
import { AudioPlayerProvider } from "./hooks/use-audio-player";
ReactDOM.createRoot(document.querySelector("main") as HTMLElement).render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<AudioPlayerProvider>
<SettingsProvider>
<CostProvider>
<ModelsProvider>
<UserProvider>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<RouterProvider router={router} />
</UserProvider>
</ModelsProvider>
</CostProvider>
</SettingsProvider>
</AudioPlayerProvider>
</ChakraProvider>
</React.StrictMode>
);
Определение хука useAudioPlayer
После определения контекста и поставщика мы, наконец, можем написать хук, который будет использовать AudioPlayerContext
предоставленный ближайшим поставщиком контекста.
const useAudioPlayer = () => useContext(AudioPlayerContext);
export default useAudioPlayer;
Использование хука
И последним шагом было использование глобального контекста аудиоплеера в тех местах, где мне хотелось бы.
В настоящее время я использую его для двух сценариев:
1. Звук предыдущего сообщения мгновенно перестанет воспроизводиться, как только пользователь задаст новый вопрос.
import useAudioPlayer from "../../hooks/use-audio-player";
...
...
const { clearAudioQueue } = useAudioPlayer();
...
...
// NOTE: we strip out the ChatCraft App messages before sending to OpenAI.
const messages = chat.messages({ includeAppMessages: false });
// Clear any previous audio clips
clearAudioQueue();
const response = await callChatApi(messages, {
functions,
functionToCall,
});
2. Любые аудиоклипы, воспроизводимые с помощью useAudioPlayer
, мгновенно остановятся, как только пользователь отключит настройку TTS.
import useAudioPlayer from "../../hooks/use-audio-player";
...
...
const { clearAudioQueue } = useAudioPlayer();
...
...
{isTtsSupported && (
<Tooltip
label={settings.announceMessages ? "Text-to-Speech Enabled" : "Text-to-Speech Disabled"}
>
<IconButton
type="button"
size="lg"
variant="solid"
aria-label={
settings.announceMessages ? "Text-to-Speech Enabled" : "Text-to-Speech Disabled"
}
icon={
settings.announceMessages ? <MdVolumeUp size={25} /> : <MdVolumeOff size={25} />
}
onClick={() => {
if (settings.announceMessages) {
// Flush any remaining audio clips being announced
clearAudioQueue();
}
setSettings({ ...settings, announceMessages: !settings.announceMessages });
}}
/>
</Tooltip>
)}
И это все, что нужно для создания и использования пользовательского хука аудиоплеера в приложении React. Я почти уверен, что на npm
уже опубликованы хуки для аудиоплееров, но сомневаюсь, что какой-либо из них подойдет моим потребностям, поскольку мне нужно поддерживать очередь.
Дайте знать в комментариях, если вы знаете лучший подход.