Собственный голосовой помощник off-line

8c5f2a68274a9b71baa6d7bb5c66d60e

Предыстория

Никогда не был любителем голосового интерфейса, но пробовал дома и Amazon Echo, и Алису. Все-таки очень долго это и недостаточно надежно — произносить фразу и думать потом — правильно ли меня поняли и всё ли сделано, как я хотел.
Но после прочтения статьи и, главное, обсуждений после нее я пришел к выводу, что есть варианты, когда это правда удобно. Собственно, самым ярким мне показался пример с кухонным таймером — не хочется грязными руками что-то трогать — голосовой интерфейс тут идеален. А попробовав приложение и почитав код коллеги @janvarev я понял, что современные средства распознавания уже вышли на очень приличный уровень и легко подключаются в проекты с открытым кодом. Дальше стало интересно сделать что-то более удобное и более стабильно работающее (без обид, но проект «Ирина» у меня не весь заработал при вменяемых затратах времени и настроек там меньше, чем мне хотелось бы).

Задумки

Целевой аудиторией приложения я вижу в первую очередь обывателя или продвинутого пользователя, знакомого с элементарной настройкой приложения через текстовый фалл или GUI. Я хорошенько обдумал с точки зрения конечного пользователя, что бы мне хотелось видеть в приложении для домашнего пользования. Конечно, рассматривал проект с точки зрения как обывателя, так который считаю основной целевой аудиторией подобных проектов. Получился довольно небольшой список хотелок.

  1. Простота установки и настройки — это либо инсталлятор и GUI для настройки, либо портативное приложение с настройками в простых текстовых или json/xml файлах (простите, но yaml я считаю крайне неудобным и опасным форматом для ручного редактирования, а старый добрый .ini не очень технологичен).

  2. Работа с явно указанными аудио устройствами — я, например, по работе использую гарнитуру, но музыку вывожу на колонки. И вовсе не очевидно, какое устройство я хочу отдать голосовому помощнику, а какое у меня задействовано для других целей. И постоянно переключаться я совершенно не хочу. А для голосового помощника вполне разумным было бы использовать отдельный внешний выносной микрофон, в идеале, с кнопкой выключения микрофона. Например, мне понравилось качество работы и исполнения здесь.

  3. Расширение функционала плагинами — тут всё очевидно — я лично не смогу (даже если захочу) обеспечить все хотелки пользователей. Например, этакая система должна легко встраиваться в «Умный дом».

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

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

  6. Невысокие требования к ресурсам — в силу ограниченности бюджета простого пользователя хотелось бы уместить все в общепринятые объемы памяти, дискового пространства и физические габариты, пригодные для установки в бытовых помещениях. Т.е. более-менее любая машина, которая тянет Windows10 (к сожалению, стандарт де-факто) уровня Celeron/Atom с 4 Gb и 64 Gb диска должна подходить. Приятно, что такие машины бывают довольно компактными (Intel NUC, Gigabyte BRIX, множество китайских машин на базе Intel BayTrail Atom Z35xx/83xx/85xx) — их можно подключить к телевизору в качестве медиаплеера и, заодно, использовать в качестве голосового помощника.

  7. Возможность несложной смены языка общения — русский язык, конечно, мне близок, но хотелось бы принести пользу максимально широкому кругу лиц. И тут надо понимать, что это 2 направления работы — язык распознавания, язык генерации речи и язык команд /плагинов.

  8. Кросс-платформенность — хорошо бы иметь возможность запускать помощника на разных системах, хотя, конечно, Windows более распространен в широких кругах…

Конечно же, все эти благие намерения необязательно получится реализовать все и сразу.

Реализация

Для реализации я выбрал язык C#, как наиболее подходящий из знакомых мне. Фреймворк — устоявшийся .NET Core 3.1 LTS.

Основные модули, на которых основывается проект:

Проект выложен на GitHub и понемногу развивается моими силами.

Идея была такова — основной модуль занимается захватом аудио и распознаванием речи, а так же поиском команд, которые определены плагинами. Плагины, в свою очередь, максимально автономны и от ядра получают только пинок «на старт», аудиопоток и поток распознанных слов (если пользователь в настройках разрешит).

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

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

На реализацию механизма ядра ушло примерно неделю вечерами — сделать, переделать, попробовать, сделать новый плагин, добавить в интерфейс еще возможностей…

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

Итак, сложность работы с командами заключается в том, что команд много, они набираются из разных плагинов, могут пересекаться, а еще они могут содержать параметры — это неизвестные мне слова или сочетания слов (что важно!). Так же, распознанные слова поступают потоком, без явно выраженных границ фраз. Тут, конечно, очень помогает то, что ориентиром начала команды является кодовое слово (имя помощника). Похожие проблемы я уже решал при написании парсеров протоколов ESC/POS, ССTalk и протокола фискального регистратора, поэтому подход применил знакомый — при получении кодового слова я формирую массив, в котором есть все возможные команды, объявленные в плагинах, и далее выкидываю из массива те команды, которые не содержат очередного пришедшего из потока слова.

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

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

  • один из них устанавливает задержку по времени, в течение которой после прихода ключевого слова должны подойти слова команды. Это более длительная задержка, во время которой пользователь обдумывает (возможно, вспоминает формат) команду, которую он хочет произнести. Я же в это время приглушаю звук на текущем основном устройстве, чтобы играющая музыка или воспроизводящийся фильм не вносил шумы в аудиоканал. После завершения ввода команды громкость звука восстанавливается. Собственно, это подсмотрено у Алисы. :) Пауза помогает мне не зависнуть, если команды так и не будет произнесено.

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

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

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

Установка

Тут все просто — модуль ядра со всем необходимым распаковываются из архива и готовы к работе сразу.

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

Модели распознавания берутся с сайта VOSK и кладутся в папку, указанную в настройках.

Модели для синтеза речи используются из реестра Windows — можно установить штатные, а можно добавить варианты от RHVoice Lab.

Настройка

К сожалению, пока у меня не было времени на написание отдельной GUI утилиты настройки, но при желании, это можно сделать. Пока что все настройки делаются в JSON-файлах — отдельных для ядра и каждого плагина.

Опишу самые важные настройки ядра:

  • «ModelFolder»: «model» — папка для модели распознавания речи.

  • «SelectedAudioInDevice»:» — устройство захвата звука. Если пусто или не найдено, то используется устройство по-умолчанию Windows. Название сравнивается как .StartsWith (), поэтому полная строка не обязательно — лишь бы не совпала с чем-то еще. Список доступных выводится в консоль при старте программы.

  • «SelectedAudioOutDevice»:» — устройство вывода звука. Если пусто или не найдено, то используется устройство по-умолчанию Windows. Название сравнивается как .StartsWith (), поэтому полная строка не обязательно — лишь бы не совпала с чем-то еще. Список доступных выводится в консоль при старте программы.

  • «CallSign»: [ «Вася» ] — набор ключевых слов или имен помощника. Ищется любое из них.

  • «DefaultSuccessRate»: 90 — коэффициент совпадения сстроки. используется пока только для поиска ключевого слова.

  • «VoiceName»: «Aleksandr» — название модуля генерации речи. Если пусто или не найдено, то используется первый с подходящей культурой. Список доступных выводится в консоль при старте программы.

  • «SpeakerCulture»: «ru-RU» — указание на язык пользователя. Используется при выборе генератора речи и передается в плагины, чтобы они знали язык пользователя. Например, таймер использует этот параметр для выбора конвертора из слов в цифры и обратно.

  • «PluginsFolder»: «plugins» — папка для плагинов

  • «PluginFileMask»:»*Plugin.dll» — маска файлов плагинов.

  • «StartSound»: «AssistantStart.wav» — звуковой файл, который проигрывается при старте помощника.

  • «MisrecognitionSound»: «Misrecognition.wav» — звуковой файл, который проигрывается при ошибке распознания команды.

  • «CommandAwaitTime»: 10 — время ожидания команды после получения ключевого слова.

  • «NextWordAwaitTime»: 3 — время ожидания следующего слова, если ввод команды уже начат.

  • «CommandNotRecognizedMessage»: «Команда не распознана» — фраза, которую ядро произносит в случае ненайденной команды. Тут можно вставить фразу на более понятном пользователю языке.

  • «CommandNotFoundMessage»: «Команда не найдена» — фраза, которую ядро произносит в случае ненайденной команды. Тут можно вставить фразу на более понятном пользователю языке.

  • «AllowPluginsToListenToSound»: false — разрешить передавать плагинам звуковой поток с микрофона.

  • «AllowPluginsToListenToWords»: false — разрешить передавать плагинам поток распознанных слов.

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

{
  "AlarmSound": "timer.wav", - файл, который роигрывается при срабатывании таймера
  "IncorrectTime": "Некорректное время", - фраза, которая произносится при нераспознанном времени
  "TimerNotFound": "Таймер не найден", - фраза, котрая произносится при попутке удаления несуществующего таймера, ко
  "Commands": [ - тут начинается список команд
    
  Первая команда - очень простая
  {
      "Response": "Таймер заведен на {1} минут", - это фраза, которую плагин произносит
  в результате успешного выполнения команды.
  "{1}" тут означает место, в которое будет вставлено значение параметра. К сожалению, пока я не успел реализовать
  свой алгормтм форматирования, чтобы тут можно было использовать более понятные метки.
  Например {%minutes%} было бы удобнее и гибче.
      "isStopCommand": false, - мне удобно было сделать отдельный флаг, разделяющий команды на установку и удаление таймеров
      "Name": "Run timer minutes", - это просто имя или описание команды. При разборе ни на что не влияет
      "Tokens": [ - это список слов,из которых состоит команда
  
  Первое слово
        {
          "Value": [ - список значений слова команды. Ищется любое из указанных
            "Поставь",
            "Заведи",
            "Запусти"
          ],
          "Type": "Command", - тип слова - команда или параметр
          "SuccessRate": 90 - коэффициент совпадения
        },

Второе слово
        {
          "Value": [
            "таймер"
          ],
          "Type": "Command",
          "SuccessRate": 90
        },

Третье слово
        {
          "Value": [
            "на"
          ],
          "Type": "Command",
          "SuccessRate": 90
        },

Четвертое слово - наконец то дошли до параметра
        {
          "Value": [
            "%minutes%" - в моем плагине по этому значению ищутся места в произнесенной фразе, где содержится параметр.
            Можно было жестко апописать номера позиций, но так появляется возможность перестраивать фразу.
          ],
          "Type": "Parameter",
          "SuccessRate": 90
        },

Пятое слово
        {
          "Value": [
            "минут",
            "минуты",
            "минуту"
          ],
          "Type": "Command",
          "SuccessRate": 90
        }
      ]
    },

  Вторая команда команда - почти копия первой, но останавливает, а не запускает таймер
    {
      "Response": "Таймер на {1} минут остановлен",
      "isStopCommand": true,
      "Name": "Stop timer minutes",
      "Tokens": [
        {
          "Value": [
            "Останови",
            "Удали"
          ],
          "Type": "Command",
          "SuccessRate": 90
        },
        {
          "Value": [
            "таймер"
          ],
          "Type": "Command",
          "SuccessRate": 90
        },
        {
          "Value": [
            "на"
          ],
          "Type": "Command",
          "SuccessRate": 90
        },
        {
          "Value": [
            "%minutes%"
          ],
          "Type": "Parameter",
          "SuccessRate": 90
        },
        {
          "Value": [
            "минут",
            "минуты",
            "минуту"
          ],
          "Type": "Command",
          "SuccessRate": 90
        }
      ]
    },
  ]
}

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

Дальнейшие планы

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

Так же, хочется подумать над кросс-платформенностью — сейчас это не возможно, в первую очередь, из-за NAudio, но, возможно, что-то получится придумать.

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

© Habrahabr.ru