Секрет внутренней связи: откровения Маруси о том, как она научилась слушать себя
Привет, Хабр! Меня зовут Коля Кремер, уже 4 года я работаю в команде мобильного приложения Маруси, где мы постоянно стараемся сделать так, чтобы с нашим помощником было удобно и интересно общаться.
Я несколько раз начинал писать и откладывал этот ретроспективный пост, но потом к нему подключились ребята из нашей команды и помог довести его до публикации. Хочу поблагодарить Влада Голоднюка, старшего программиста, и Пашу Муханова, руководителя мобильной разработки, и рассказать сообществу Хабра о том, как мы отучили Марусю в приложении триггериться на себя и научили правильно слышать и понимать ваши запросы.
Что такое самотриггерение?
Когда четыре года назад мы запустили Марусю, она уже была сообразительной и помогала решать самые разные задачи: от поиска интересных фильмов в кинотеатрах и треков в VK Музыке до рассказа сказок для детей и прогнозов погоды. Кроме того, она могла (и может) поддерживать беседу и давать осмысленные ответы. Однако ряд пользователей сталкивался с ситуацией, когда помощник останавливался в середине ответа, как будто обдумывая следующее слово. Это сбивало с толку, хотя причина была в том, что Маруся внимательна к вам во время разговора, чтобы собеседник мог прервать её если необходимо. Следовательно, когда в её ответе или ваших словах встречаются KWS (key word spotter — слово, на которое голосовой помощник триггерится), то, слыша слово «Маруся» (которое и есть KWS), она думает, что вы обратились к ней, останавливая выполнение всех текущих команд.
Очевидно, что это не самая приятная ситуация и для нас и для пользователя. Именно это называется проблемой самотриггерения — ситуации, при которой голосовой помощник активируется (триггерится) сам на себя.
Однако не стоит думать, что Маруся находится в постоянном состоянии самотреггерения. Для большой части запросов у неё, как и у всех retrieval-based ассистентов, есть набор стандартных ответов и шаблонов, из которых она выбирает наиболее подходящий. Как правило, их конечное число, поэтому мы храним их на сервере не в текстовом виде, а как текст и аудио (tts — text to speech — аудиодорожка с озвучкой текста Маруси). Вместе с этим для каждой такой аудиодорожки можно создать и хранить timestamp (временные метки), в которых есть триггерное слово «Маруся». Это необходимо для того, чтобы для каждого такого timestamp сервер слал команду skip_kws
на клиент (мобильное приложение) для блокировки микрофона.
Как это работает?
Пользователь что-то спрашивает у Маруси, например, какая завтра погода, а она ищет наиболее подходящий ответ. Если он есть в списке готовых с аудиодорожками и командами skip_kws
, то всё работает верно. В этом случае сервер присылает в качестве ответа текст, аудиодорожку и команду skip_kws
с параметрами на каких секундах приложение нужно блокировать микрофон. Далее приложение показывает пользователю текст, начинает озвучивать аудиодорожку (TTS) и в нужный момент отключает микрофон, чтобы исключить проблему самотриггерения.
А что если для ответа нет готового TTS, т.е. он генерируется нейросетью или это подкаст или трек VK Музыки, то команды skip_kws
нет, и приложение не знает, когда нужно блокировать микрофон. Поэтому, если в ответе есть триггерное слово, то Маруся реагирует сама на себя.
На большинство ответов Маруся отвечает без запинок, что в целом очень хорошо, плюс мы постоянно получаем обратную связь (отзывы в магазинах, обращения в поддержку группы в ВКонтакте, чат с корпоративными пользователями и т. д.) и работаем над улучшениями. При этом жалоб на самотриггерение мы получили единицы на миллионы пользователей в месяц.
Как мы обнаружили проблему самотриггерения?
На одном из UX‑исследований с фокус группами мы обнаружили, что пользователи жаловались на самотриггерение, говоря о том, что Маруся «тормозит» и «сбивается». То есть пользователи воспринимали это не как проблему самотриггерения, они просто считали, что Маруся не может ответить на вопрос. Стало понятно, что проблема немного больше, чем мы предполагали.
Для исследования мы собрали с добровольных тестеров выборку в 2000 случайных аудиодорожек, в которых проверили нулевой чанк — начало аудиодорожки, т. е. момент активации Маруси. Результаты не были плачевными, но и не обрадовали нас: 6% из 2000 активаций были самотриггерением. Небольшое отступление, важно понимать, что если бы починка бага занимала мало времени и ресурсов, то мы бы запланировали её в ближайший релиз в рамках ZBP (Zero Bug Policy), но готового простого решения не нашлось, поэтому пришлось начинать с его поиска.
Как мы выбирали из всех возможных вариантов решения
Для начала мы обратились к опыту конкурентов, посмотрели, как сегодня работают их голосовое ассистенты и пришли к следующим выводам:
Конкурент 1: Во время озвучки TTS полностью выключает микрофон. Сценарий нам не подошел, т.к. пользователю нужно дождаться, пока ассистент закончит вещать или перебить его тапом по кнопке. После этого можно опять активировать ассистента тапом или по kws и задать новый вопрос.
Конкурент 2: Перед использованием требует дообучения модели на голосе пользователя. Что это значит? На онбординге ассистент просит пользователя зачитать текст, и записанный голос пользователя использует для дообучения локальной модели, которая распознает KWS. Таким образом, ассистент отличает вызов KWS пользователем от самого себя. Продуктовое решение классное, хорошо решает проблему самотриггерения, но требует долгой работы ML-команды. Забрали в бэклог.
Конкурент 3: Помощник отключает микрофон девайса в момент озвучивания им ключевого слова, в нашем случае Маруся отключала бы микрофон каждый раз, когда она должна сказать слово «Маруся» при ответе на вопрос пользователя. Решение неплохое, но есть минусы: не работает на эхо, создаваемое другими приложениями: стриминг музыки, подкасты и т. д. Кладем в бэклог со звездочкой.
Конкурент 4: также как и мы, ассистент страдает проблемой самотриггерения.
Затем мы попытались найти простое решение. Например, с помощью активных инструментов.
Сначала про Android
При инициализации записи объекту передается источник записи с помощью MediaRecorder::setAudioSource(int)
.
Для микрофона имеется стандартный параметр MediaRecorder.AudioSource.MIC
, который будет возвращать в колбек аудиодорожку, записанную микрофоном, после постобработки средствами устройства.
Также имеются дополнительные константы:
MediaRecorder.AudioSource.VOICE_COMMUNICATION
MediaRecorder.AudioSource.VOICE_RECOGNITION
Их добавили для обработки входящего и исходящего потока средствами ОС, для лучшего качества общения. Согласно документации, они главным образом существуют для шумо и эхоподавления, то есть для решения нашей проблемы.
Также существует класс AcousticEchoCanceler
, который позволяет отдельно подключать системное эхоподавление к отдельной сессии, но, как можно заметить по его интерфейсу, метод isAvailable() говорит, что это может работать не на всех Android-устройствах.
На самом деле этот класс задействует те же системные инструменты, что и MediaRecorder
при передаче описанных выше констант VOICE_COMMUNICATION
и VOICE_RECOGNITION
, но кастомные прошивки могут вообще этого не делать, поэтому если вы решитесь использовать эти инструменты, следует отдельно пытаться подключить эхоподавление для большей надежности.
Из плохих новостей: то, как будет обрабатываться запись не имеет конкретной спецификации, а движок системного эхоподавления реализован каждым отдельным производителем в недрах операционной системы. Читая между строк: на большинстве устройств будет работать очень плохо или вообще не будет.
В итоге нативное решение в большинстве случаев никак не работало. Все зависело от конкретной модели: в околофлагманских устройствах удаляло эхо до определенной громкости динамиков, а в смартфонах попроще не только не работало, но и искажало проигрываемый звук из динамиков из-за попытки обработать исходящий поток (создавало артефакты и помехи).
Теперь про iOS
Реализуется максимально просто с помощью стандартных инструментов: класс AudioUnit.
desc.componentSubType = kAudioUnitSubType_VoiceProcessingIO
Нативно решение работает частично: немного искажает исходящий звук и сильно приглушает его: создает эффект, будто звук проигрывается из банки, но работает достаточно бодро. Как можно узнать из документации: системное эхоподавление задумывалось как решение для приложений звонков, которые могут уживаться с такими недостатками.
Обобщая результат по двум платформам, можно резюмировать, что системное эхоподавление работает с большим количеством ошибок и может искажать исходящий звук, что не подходит для голосового ассистента, который использует звук как средство коммуникации — benefits < drawbacks. Не берём в бэклог.
Как это делают умные колонки?
Умная колонка — полностью кастомный продукт со своей прошивкой. Поэтому она может получать в реальном времени поток с микрофона и звук из динамиков, вычищая шумы и получая чистый звук. Такая реализация — эталон по отношению к тому, что должно быть реализовано в системном движке эхоподавления смартфонов, и должно быть реализовано средствами производителя, но, как было написано ранее, реализовывается на малом количестве флагманских смартфонов. Поэтому для мобильного приложения это решение не подходит.
Сторонние библиотеки
Их нет и не может быть, так как решение по эхоподавлению должно быть очень низкоуровневым. На уровне приложения такое решение не представляется возможным.
Как делали
Самым оптимальным решением является блокировка микрофона, но не во время всего проигрывания TTS, а только ключевого слова. Механизм выглядит простым, а значит надежным — компромисс между затрачиваемыми ресурсами и результатом.
Из этого вытекает два вопроса: откуда берем звуковую дорожку и каким образом будем понимать, когда нужно отключать микрофон (т. е. некий обработчик звуковой дорожки)? В качестве идеального решения была бы возможность получать весь звук, который проигрывается устройством, но во‑первых, это невозможно сделать из‑за особенностей OS, во‑вторых, это не очень этично.
В нашем приложении на Android для проигрывания звуков мы используем ExoPlayer2
В нём есть возможность перехватывать аудиодорожку на этапе её чтения из источника, за что отвечает интерфейс DataReader. Соответ ственно, самый простой способ получить аудиодорожку — написать свой DataSource
, который и является наследником DataReader, в методе int read(byte[] buffer, int offset, int len gth)
которого мы и будем производить необходимые вычисления
В iOS грузим данные через URLSession. В методе init
можно передать URLSessionDelegate
, который и необходимо реализовать в качестве колбека для данных.
Для вычисления ключевого слова из потока микрофона мы используем несколько вероятностных моделей, которые объединены в библиотеку на C++, которую мы используем через Java Native Interface и Swift bridging.
Логично, что если мы используем эту модель для одного источника звука, мы можем использовать её и для анализа других источников, так как в конечном счете звук представлен в виде байтового массива. При этом модели не важно, какого он происхождения.
Таким образом мы будем прогонять наш звук из плеера через модели машинного обучения для поиска ключевого слова.
Здесь важно отметить, что такие модели обычно обучаются под конкретный формат аудио, поэтому предварительно следует убедиться, что формат совпадает. В противном случае необходимо привести массив к нужному формату или переобучить модели. В нашем случае формат совпадал, поэтому перекодирование и переобучение не потребовалось.
Также необходимо брать в расчет, что несколько дорожек может загружаться параллельно, либо может существовать несколько инстансов плеера одновременно, которые подготавливают дорожки (на самом деле это плохая практика из-за ограничений по использованию кодеков на смартфоне, но сейчас разговор не об этом), тогда необходимо обеспечить параллельность вычислений для каждой аудиодорожки в разных потоках. В нашем случае нативная библиотека не была рассчитана на многопоточную работу, так как использовалась только для микрофона в отдельном потоке, из-за чего её нельзя было корректно использовать с плеером, поэтому нам пришлось доработать её для работы в многопоточном режиме.
Допустим, мы получили, что в конкретном месте массива байтов модель нашла ключевое слово, но все же как мы поймем, когда надо отключать микрофон?
Как сказал ранее, модель работает с аудио определенного формата, следовательно, мы можем вычислить количество байтов в секунду и по расположению нужного участка на всем массиве вычислить нужное время на таймлайне аудиодорожки. Осталось повесить Listener на прогресс плеера и отключать микрофон на найденных интервалах.
И всё?
Нет, не всё. Когда всё уже было сделано, оказалось, что одна и та же модель не всегда находит ключевое слово в исходной аудиодорожке, если оно не точно такое, а искажено или просто похоже по звучанию. Например, если вместо четкого «Маруся» было произнесено «Марущ» или любое другое похожее на ключевое слово, но находит его во входящем потоке, который попадает в микрофон, причем чем ниже громкость динамика, тем чаще происходит такая ошибка.
Всё дело в том что анализировали мы идеально чистый звук, а эхо, которое попадает в микрофон искажается по пути (из-за обработки исходящего и входящего звука операционной системой, плохого качества динамиков или микрофона и наложения внешних шумов), из-за чего в ряде случаев искажение звука приводило к тому, что модель начинала слышать ключевое слово там где его на самом деле нет.
Для исправления этого эффекта потребовалась донастройка модели. Сама модель не возвращает булевое значение, является ли оно ключевым словом или нет, но возвращает вероятность этого, а подставив нужные пороги вероятностей, уже можно получить булевое значение. Так как для модели, которая использовалась микрофоном, уже были заданы необходимые вероятностные значения, необходимо было изменить коэффициенты для модели, которая использовалась при анализе плеера.
Здесь мы будем рассматривать модель, которой передаются пороги вероятностей в качестве коэффициентов, как булеву функцию от байтового массива f1(a, x), где a — искомые коэффициенты, а x — массив байтов.
Как упоминалось ранее, параметры модели для микрофона уже заданы, следовательно она является функцией с заданными коэффициентами f2(x), при этом исходный массив байтов искажается по пути до f2, следовательно существует некоторая случайная функция искажения b(x), которая возвращает новый массив байтов. Ранее упоминал, что эта функция в том числе зависит от громкости звука динамиков, поэтому точнее будет ввести параметр v, который принимает целочисленные значения 1–10 (10–100%): b(v, x)
Таким образом, нам необходимо подобрать такое значение a, при котором функции f1(a, x) и f2(b(v, x)) как можно чаще выдают одинаковый ответ. т. е. Такие значения, что
f1(a, x) ≈ f2(b(v, x))
Так как для нас пороги вероятностей являются целочисленными значениями от 0 до 100, то найти оптимальное значение можно простым перебором. Так как все это происходит на реальных девайсах, подбирать мы будем там же через написанный скрипт. Необходимо собрать большой массив аудиодорожек в качестве тестового набора данных, где в том числе будут различные искаженные или похожие варианты ключевого слова, после чего необходимо проиграть эти аудиодорожки при разных значениях громкости динамика, поймать микрофоном и проанализировать. Ответы для разных значений громкости всегда можно усреднить. Далее анализируем исходные дорожки с разными порогами вероятностей в цикле и находим те, при которых количество ошибок минимальное, при этом стоит учитывать, что ложное срабатывание при анализе исходной дорожки является менее серьезной проблемой, чем пропуск ключевого слова, поэтому следует добавить разные веса этим ошибкам.
Что в итоге?
После разработки решения для начала мы раскатали его на небольшую аудиторию, проверили эффективность: доля самотриггерений голосового помощника в потоке упала с 6% до 0,1%, пользователи остались довольны. Сейчас мы обновляем SDK для VK и Почты, чтобы Маруся больше нигде на мобильных устройствах не триггерилась.