Хотя OpenAI откладывает выпуск расширенных голосовых режимов для ChatGPT, я хочу рассказать, как мы создали голосовое приложение LLM и интегрировали его в интерактивный стенд.
В конце февраля на Бали прошел фестиваль Лампу , устроенный по принципам знаменитого Burning Man. По традиции участники создают собственные инсталляции и арт-объекты.
Я и мои друзья из лагеря 19:19 , вдохновленные идеей католических исповеданий и возможностями нынешних магистратур, пришли к идее построить собственную ИИ-исповедальню, где каждый мог бы поговорить с искусственным интеллектом.
Вот как мы это себе представляли в самом начале:
Чтобы протестировать концепцию и начать экспериментировать с подсказкой для LLM, я за один вечер создал наивную реализацию:
Для реализации этой демонстрации я полностью полагался на облачные модели от OpenAI: Whisper , GPT-4 и TTS . Благодаря отличной библиотеке voice_recognition я построил демо всего за несколько десятков строк кода.
import os import asyncio from dotenv import load_dotenv from io import BytesIO from openai import AsyncOpenAI from soundfile import SoundFile import sounddevice as sd import speech_recognition as sr load_dotenv() aiclient = AsyncOpenAI( api_key=os.environ.get("OPENAI_API_KEY") ) SYSTEM_PROMPT = """ You are helpfull assistant. """ async def listen_mic(recognizer: sr.Recognizer, microphone: sr.Microphone): audio_data = recognizer.listen(microphone) wav_data = BytesIO(audio_data.get_wav_data()) wav_data.name = "SpeechRecognition_audio.wav" return wav_data async def say(text: str): res = await aiclient.audio.speech.create( model="tts-1", voice="alloy", response_format="opus", input=text ) buffer = BytesIO() for chunk in res.iter_bytes(chunk_size=4096): buffer.write(chunk) buffer.seek(0) with SoundFile(buffer, 'r') as sound_file: data = sound_file.read(dtype='int16') sd.play(data, sound_file.samplerate) sd.wait() async def respond(text: str, history): history.append({"role": "user", "content": text}) completion = await aiclient.chat.completions.create( model="gpt-4", temperature=0.5, messages=history, ) response = completion.choices[0].message.content await say(response) history.append({"role": "assistant", "content": response}) async def main() -> None: m = sr.Microphone() r = sr.Recognizer() messages = [{"role": "system", "content": SYSTEM_PROMPT}] with m as source: r.adjust_for_ambient_noise(source) while True: wav_data = await listen_mic(r, source) transcript = await aiclient.audio.transcriptions.create( model="whisper-1", temperature=0.5, file=wav_data, response_format="verbose_json", ) if transcript.text == '' or transcript.text is None: continue await respond(transcript.text, messages) if __name__ == '__main__': asyncio.run(main())
Проблемы, которые нам предстояло решить, сразу стали очевидны после первых тестов этой демо-версии:
У нас был выбор, как решить эти проблемы: искать подходящее инженерное или продуктовое решение.
Прежде чем мы приступили к написанию кода, нам нужно было решить, как пользователь будет взаимодействовать со стендом:
Для обнаружения нового пользователя в кабинке мы рассмотрели несколько вариантов: датчики открытия дверей, датчики веса пола, датчики расстояния и модель камера + YOLO. Датчик расстояния за спиной нам показался самым надежным, так как исключал случайные срабатывания, например, когда дверь закрыта недостаточно плотно, и не требовал сложной установки, в отличие от датчика веса.
Чтобы избежать проблем с распознаванием начала и конца диалога, мы решили добавить большую красную кнопку для управления микрофоном. Это решение также позволило пользователю прервать работу ИИ в любой момент.
У нас было много разных идей по реализации обратной связи при обработке запроса. Мы остановились на варианте с экраном, на котором видно, что делает система: слушает микрофон, обрабатывает вопрос или отвечает.
Мы также рассматривали довольно разумный вариант со старым стационарным телефоном. Сеанс начинался, когда пользователь поднимал трубку, и система слушала пользователя, пока он не повесил трубку. Однако мы решили, что более аутентично, когда пользователю «отвечает» кабинка, а не голос из телефона.
В итоге окончательный пользовательский поток получился таким:
Ардуино следит за состоянием датчика расстояния и красной кнопки. Все изменения он отправляет на наш бэкенд через HTTP API, что позволяет системе определить, вошел ли пользователь в кабинку или вышел из нее и нужно ли активировать прослушивание микрофона или начать генерировать ответ.
Веб -интерфейс — это просто веб-страница, открываемая в браузере, которая постоянно получает текущее состояние системы от серверной части и отображает его пользователю.
Бэкэнд управляет микрофоном, взаимодействует со всеми необходимыми моделями ИИ и озвучивает ответы LLM. Он содержит основную логику приложения.
Как закодировать скетч для Arduino, правильно соединить датчик расстояния и кнопку и собрать все это в стенде – тема для отдельной статьи. Давайте кратко рассмотрим, что у нас получилось, не вдаваясь в технические подробности.
Мы использовали Arduino, точнее модель ESP32 со встроенным модулем Wi-Fi. Микроконтроллер был подключен к той же сети Wi-Fi, что и ноутбук, на котором выполнялась серверная часть.
Полный список оборудования, которое мы использовали:
Основными компонентами конвейера являются преобразование речи в текст (STT), LLM и преобразование текста в речь (TTS). Для каждой задачи доступно множество различных моделей как локально, так и через облако.
Поскольку у нас не было под рукой мощного графического процессора, мы решили остановить свой выбор на облачных версиях моделей. Слабым местом этого подхода является необходимость хорошего подключения к Интернету. Тем не менее, скорость взаимодействия после всех оптимизаций была приемлемой, даже при наличии у нас на фестивале мобильного интернета.
Теперь давайте более подробно рассмотрим каждый компонент конвейера.
Многие современные устройства уже давно поддерживают распознавание речи. Например, API Apple Speech доступен для iOS и macOS, а API Web Speech — для браузеров.
К сожалению, по качеству они сильно уступают Whisper или Deepgram и не могут автоматически определить язык.
Чтобы сократить время обработки, лучшим вариантом является распознавание речи в реальном времени по мере того, как говорит пользователь. Вот несколько проектов с примерами их реализации:
На нашем ноутбуке скорость распознавания речи при таком подходе оказалась далека от реальной. После нескольких экспериментов мы остановились на облачной модели Whisper от OpenAI.
Результатом модели «Речь в текст» из предыдущего шага является текст, который мы отправляем в LLM с историей диалогов.
При выборе LLM мы сравнивали GPT-3.5. GPT-4 и Клод. Оказалось, что ключевым фактором была не столько конкретная модель, сколько ее комплектация. В конечном итоге мы остановились на GPT-4, ответы которого нам понравились больше остальных.
Кастомизация подсказки для моделей LLM стала отдельным видом искусства. В Интернете есть множество руководств, как настроить свою модель так, как вам нужно:
Нам пришлось много экспериментировать с настройками подсказки и температуры , чтобы модель реагировала увлекательно, лаконично и с юмором.
Мы озвучиваем полученный от LLM ответ, используя модель Text-To-Speech, и воспроизводим его пользователю. Этот шаг был основной причиной задержек в нашей демо-версии.
LLM отвечают довольно долго. Однако они поддерживают генерацию ответа в потоковом режиме — токен за токеном. Мы можем использовать эту функцию для оптимизации времени ожидания, озвучивая отдельные фразы по мере их поступления, не дожидаясь полного ответа от LLM.
Время, пока пользователь прослушивает исходный фрагмент, мы используем, чтобы скрыть задержку обработки остальных частей ответа от LLM. Благодаря такому подходу задержка ответа возникает только в начале и составляет ~3 секунды.
async generateResponse(history) { const completion = await this.ai.completion(history); const chunks = new DialogChunks(); for await (const chunk of completion) { const delta = chunk.choices[0]?.delta?.content; if (delta) { chunks.push(delta); if (chunks.hasCompleteSentence()) { const sentence = chunks.popSentence(); this.voice.ttsAndPlay(sentence); } } } const sentence = chunks.popSentence(); if (sentence) { this.voice.say(sentence); } return chunks.text; }
Даже при всех наших оптимизациях задержка в 3-4 секунды все равно значительна. Мы решили позаботиться об интерфейсе с обратной связью, чтобы избавить пользователя от ощущения, что ответ зависает. Мы рассмотрели несколько подходов:
Мы остановились на последнем варианте с простой веб-страницей, которая опрашивает серверную часть и показывает анимацию в соответствии с текущим состоянием.
Наша исповедальная комната с искусственным интеллектом работала четыре дня и привлекла сотни посетителей. Мы потратили всего около 50 долларов на API OpenAI. Взамен мы получили существенные положительные отзывы и ценные впечатления.
Этот небольшой эксперимент показал, что добавить к LLM интуитивно понятный и эффективный голосовой интерфейс можно даже при ограниченных ресурсах и сложных внешних условиях.
Кстати, исходники бэкенда доступны на GitHub.