Пока OpenAI задерживают релиз звуковой модальности для ChatGPT, я хочу поделиться, как мы собрали свое приложение для голосового взаимодействия с LLM и интегрировали его в интерактивную кабинку.

Поговорить с ИИ в джунглях

В конце февраля на Бали прошел фестиваль Lampu, организованный по принципам знаменитого Burning Man. По его традиции, участники самостоятельно создают инсталляции и арт-объекты.

Мы с друзьями из кемпа 19:19, вдохновившись идеей католических исповедален, придумали сделать свой AI Confession Room, где любой желающий мог бы поговорить с искусственным интеллектом. Вот как мы это себе представляли:

  • Пользователь заходит в кабинку, мы определяем, что необходимо начать новую сессию.

  • Пользователь задает вопрос, ИИ слушает и отвечает на него. Мы хотели создать атмосферу доверия и конфиденциальности, где каждый мог бы открыто говорить о своих мыслях и переживаниях.

  • Когда пользователь покидает кабинку, система завершает сессию и забывает все подробности диалога. Это необходимо для сохранения приватности всех диалогов.

Демо версия

Для проверки концепции и начала экспериментов с промптом для LLM, я за один вечер создал наивную реализацию:

  • Слушаем микрофон

  • Распознаем речь пользователя с помощью Speech-to-Text (STT) модели.

  • Генерируем ответ через LLM

  • Синтезируем голосовой ответ с помощью Text-to-Speech (TTS) модели

  • Воспроизводим ответ пользователю

Для реализации этой демки я решил полностью полагаться на облачные модели от OpenAI: Whisper, GPT-4 и TTS. Благодаря крутой библиотеке speech_recognition такой сценарий можно собрать всего в несколько десятков строк кода.

Демо-версия на Python со speech_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())

После первых тестов нашей демки сразу стали понятны проблемы, которые нам предстоит решить:

  • Задержка ответа. В наивной реализации задержка между вопросом пользователя и ответом составляет от 7-8 секунд и дольше. Никуда не годится, но, очевидно, что есть много способов оптимизировать время ответа.

  • Окружающий шум. Мы поняли, что в шумном окружении мы не можем полагаться на микрофон, чтобы автоматически определять, когда пользователь начал и закончил говорить. Автоматическое распознавание начала и конца фразы (endpointing) — это нетривиальная задача сама по себе. Помножьте ее на шумное окружение на музыкальном фестивале и станет понятно, что здесь нужен какой-то концептуально другой подход.

  • Имитация живого общения. Мы хотели оставить пользователю возможность перебивать ИИ. Для этого нам бы пришлось держать микрофон постоянно включенным. Но в этом случае нам бы пришлось отделять голос пользователя не только от фоновых звуков, но и от голоса ИИ.

  • Обратная связь. Из-за задержки ответа нам иногда казалось, что система подвисла. Мы поняли, что нужно каким-то образом сообщать пользователю на каком этапе находится обработка ответа.

Каждую из этих проблем можно было постараться решить техническими средствами или обойти с продуктовой стороны.

Продумываем UX кабинки

Перед тем, как начать программировать, нам нужно было определиться, как пользователь будет взаимодействовать с нашей будкой:

  • Как определить, что в кабинке новый пользователь, чтобы сбросить историю прошлого диалога?

  • Как распознать начало и конец речи пользователя, и что делать, если он захочет перебить ИИ?

  • Как реализовать обратную связь при задержке ответа от ИИ?

Чтобы определить, что в кабинке новый пользователь, мы рассматривали несколько вариантов: датчики открытия двери, датчик веса на полу, датчик расстояния, камера + YOLO-модель. Датчик расстояния за спиной нам показался самым надежным, так как он исключал случайные срабатывания, например, при недостаточно плотно закрытой двери и не требовал сложной установки в отличии от датчика веса.

Чтобы упростить себе жизнь и не решать задачу распознавания начала и конца диалога, мы решили добавить большую красную кнопку для управления микрофоном. Такое решение также позволило пользователю перебивать ИИ в любой момент.

У нас было много разных идей, как можно реализовать фидбэк об обработке запроса. Мы остановились на варианте с экраном, на котором отображено, чем занята система: слушает микрофон, обрабатывает вопрос или отвечает.

Мы так же рассматривали достаточно красивый вариант со старым стационарным телефоном, где сессия начиналась бы при поднятии трубки, и система слушала бы пользователя, пока трубка остается поднятой. Однако мы решили, что гораздо круче, когда пользователю “отвечает“ сама кабинка, а не голос в трубке.

Во время монтажа и на фестивале
Во время монтажа и на фестивале

В итоге, финальный сценарий вышел вот таким:

  • Пользователь заходит в кабинку. У него за спиной срабатывает датчик расстояния и мы приветствуем его.

  • Чтобы начать диалог, пользователь нажимает красную кнопку. Пока кнопка нажата, мы слушаем микрофон. Когда пользователь отпустил кнопку, мы начинаем обрабатывать запрос и сигнализируем об этом на экране.

  • Если пользователь хочет задать новый вопрос во время ответа ИИ, он может нажать кнопку снова, и ИИ немедленно прекратит ответ.

  • Когда пользователь выходит из кабинки, снова срабатывает датчик расстояния — история диалога сбрасывается.

Архитектура

Arduino отслеживает состояние датчика расстояния и факт нажатия на красную кнопку. Все изменения передаются на наш бэкенд через HTTP API, что позволяет системе определить, вошел ли пользователь в кабинку или покинул её, а также нужно ли активировать прослушивание микрофона или начать генерацию ответа.

Веб интерфейс — страничка, открытая в браузере, которая непрерывно получает с бэкенда текущее состояние системы и отображает его пользователю.

Бэкенд управляет микрофоном, взаимодействует со всеми необходимыми моделями, а также озвучивает ответы ИИ. Основная логика сконцентрирована именно здесь.

Железо

Как написать скетч для Arduino, правильно подключить датчик расстояния и кнопку, собрать это все в кабинке — тема для отдельной статьи. Пока что давайте кратко рассмотрим, что у нас получилось без углубления в технические детали.

Мы использовали Arduino, а точнее модель ESP32 со встроенным Wi-Fi модулем. Одноплатник был подключен к той же Wi-Fi сети, что и ноутбук с запущенным на нём бэкендом.

в процессе сборки
в процессе сборки

Вот полный список железа, которое мы использовали:

Бэкенд

Основных компоненты пайплайна — Speech-To-Text (STT), LLM, Text-To-Speech (TTS). Для каждой из этих задач существует множество различных моделей, доступных как локально, так и через облако.

У нас под рукой не оказалось мощной видеокарты, поэтому мы решили использовать облачные версии моделей. Слабое место такого подхода — необходимость хорошего интернет-соединения. Тем не менее, даже с мобильным интернетом, который был у нас на фестивале, скорость взаимодействия после всех оптимизаций оказалась приемлемой.

Теперь давайте более подробно рассмотрим каждый компонент пайплайна.

Speech Recognition

Распознавание речи — функция, которую многие современные устройства поддерживают уже очень давно. Например, для устройств на базе iOS и macOS доступен Speech API от Apple, а для браузеров предлагается Web Speech API.

К сожалению, они очень сильно уступают по качеству тому же Whisper или Deepgram и, кроме того, не умеют автоматически определять язык.

Чтобы сократить время обработки, лучший вариант — это распознавать речь в реальном времени по мере того, как пользователь говорит. Вот несколько проектов с примерами того, как это можно реализовать: whisper_streaming, whisper.cpp

На нашем слабом ноуте скорость распознавания речи с использованием этого подхода оказалась далекой от реалтайма. В итоге, после нескольких экспериментов мы остановились на облачной модели Whisper от OpenAI.

LLM и Prompt Engineering

Результат работы Speech-To-Text модели из прошлого шага — текст, который мы отправляем в LLM вместе с историей диалога.

Выбирая LLМ, мы сравнивали между собой GPT-3.5. GPT-4 и Claude. Оказалось, что ключевым фактором стала не столько конкретная модель, сколько её грамотная настройка. В итоге мы остановились на GPT-4, ответы которого нам нравились больше других.

Настройка промпта для LLM моделей успела превратиться в отдельный вид искусства. В интернете можно найти много гайдов, помогающих настраивать модель как вам нужно:

Нам пришлось долго экспериментировать с промптом и настройками температуры, чтобы заставить модель отвечать интересно, лаконично и с долей юмора.

Text-To-Speech

Ответ, полученный от LLM, мы озвучиваем с помощью Text-To-Speech модели и проигрываем его пользователю. Этот этап был основным источником задержек в нашей демо-версии.

LLM отвечают достаточно долго. Однако они умеют генерировать ответ в режиме стриминга — токен за токеном. Мы можем использовать эту особенность, чтобы оптимизировать время ожидания, озвучивая отдельные фразы по мере получения, не дожидаясь полного ответа от LLM.

Озвучиваем отдельные предложения
Озвучиваем отдельные предложения
  • Делаем запрос к LLM в режиме стриминга.

  • Накапливаем ответ в буфер токен за токеном, пока у нас не получится законченное предложение минимальной длины. Кстати, параметр минимальной длины очень важен, так как он влияет и на интонацию озвучивания и на время начальной задержки.

  • Отправляем сформированное предложение в TTS модель, результат проигрываем пользователю. На этом шаге необходимо следить за тем, чтобы не было race-condition — соблюдать порядок воспроизведения.

  • Повторяем предыдущий шаг до тех пор, пока не получим ответ от 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 секунды все-таки довольно существенная. Чтобы избавить пользователя от ощущения, что ответ завис, мы решили позаботиться об UI с фидбеком. Мы рассмотрели несколько подходов:

  • Индикаторы в виде лампочек. Нам нужно было отразить целых 5 разных состояний: idle, waiting, listening, thinking, speaking. Но мы не смогли придумать, как это сделать удобно и понятно с помощью лампочек

  • Голосовые вставки “Дай мне подумать”, ”Ммм” и так далее, имитирующие живую речь. Отказались от этого варианта, потому что такие вставки часто не соответствовали тону ответов модели

  • Разместить в кабинке экран. И отображать разные состояния анимацией на нем

Мы остановились на последнем варианте с простой веб-страничкой, которая поллит бэкенд и показывает анимацию в соответсвии с текущим состоянием.

экран в кабинке
экран в кабинке

Итоги

Наша ИИсповедальня проработала четыре дня и привлекла внимание сотен участников. Потратив около $50 на OpenAI API, мы получили огромное количество положительных отзывов и ценных впечатлений.

Этот небольшой эксперимент показал, что можно добавить интуитивно понятный и эффективный голосовой интерфейс к LLM даже при ограниченных ресурсах и сложных внешних условиях.

Кстати, исходники бэкенда доступны на гитхабе.

Комментарии (7)


  1. Ranger21
    09.07.2024 04:03
    +3

    При реализации были ли консультации с представителями религии? Священники одобрили?)


  1. Pol1mus
    09.07.2024 04:03

    Видеозапись есть? Интересно посмотреть как это в реальности выглядит.


    1. sobolevslava Автор
      09.07.2024 04:03

      Прямую ссылку скинуть не получается, но вот ссылка на хайлайтс в инсте. На 2 сторьке есть.