Как я научил Пип-бой понимать голосовые команды

Прокомментировать Просмотры: 10

Квест с электростанцией и нептуниевой крыльчаткой. Олды на месте?

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

Дисклеймер. Я не профессиональный программист, а рядовой технический писатель. По совместительству — преданный фанат Fallout, у которого есть друг-девопс с 3D-принтером, десяток безумных идей, подписка на Claude и руки, которые растут из плеч. Точнее, оттуда, где вечно зудит.

Предыстория

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

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

Однако меня огорчало одно обстоятельство: полное отсутствие робототехники. И это в Fallout! В той самой франшизе, где я еще школьником пытался разобраться с десинхронизацией нептуниевой крыльчатки.

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

Концепция устройства. Гаджет должен считывать голодиски, отображать карту полигона с GPS-трекингом, транслировать внутриигровое радио, оснащаться встроенным счетчиком Гейгера, реагирующим на радиационный фон, а также поддерживать ряд специфических механик ролевого процесса.

Смартфон на базе Android монтируется внутрь корпуса, выполняя функции вычислительного ядра и дисплея. Периферия подключается по протоколу BLE. Поскольку стабильного интернета на полигоне обычно нет, вся архитектура обязана функционировать в автономном режиме.

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

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

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

Архитектура

Система голосового ввода опирается на три фундаментальных модуля:

  • Движок активационной фразы (вейк-слова). Этот компонент непрерывно анализирует аудиопоток с микрофона, вычленяет ключевую фразу и переводит систему в режим ожидания команды. Существует коммерческий аналог Porcupine, но мы предпочитаем опенсорс, поэтому выбор пал на openWakeWord — гибкий фреймворк для обучения собственных триггерных слов.

  • Модуль распознавания речи. Здесь победителем также вышел проект с открытым исходным кодом — Vosk. Он работает локально и предлагает относительно компактную языковую модель для Android (порядка 50 Мб на русскую языковую модель и сопоставимый объем под нативные библиотеки). Чтобы не раздувать размер APK-файла, я добавил в настройки пип-боя опцию ручного импорта модели.

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

Обучение активационного слова

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

В качестве тренировочной площадки использовался Google Colab: его бесплатная версия выделяет ограниченный доступ к GPU, чего с избытком хватило для моих задач, несмотря на прогнозы о долгом времени обучения.

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

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

n_samples

классификатор

max_negative_weight

recall

accuracy

fp/час

2000

1 блок (Linear‑LayerNorm‑ReLU)

1500

0.603

0.792

0.00

2000

1 блок

200

0.605

0.791

1.70

6000

1 блок

600

0.721

0.850

1.25

12 000

1 блок

600

0.721

0.855

0.80

12 000

2 блока

600

0.758

0.872

1.95

12 000 + 1000 русских

2 блока

1000

0.750*

0.868*

0.25

* метрики на бумаге слегка просели, однако на практике модель стала значительно лояльнее к славянскому акценту, чего и добивались.

Первая рабочая гипотеза предполагала, что штраф за ложные срабатывания завышен, и его снижение должно поднять полноту (recall). Она потерпела сокрушительное фиаско. Как видно из таблицы, точность и частота распознавания не изменились, но количество ложных срабатываний взлетело до небес.

Далее я расширил датасет до 6000 сэмплов, одновременно скорректировав штрафные коэффициенты — это привело к качественному скачку: полнота достигла 0.721, а общая точность возросла до 0.850. Тем не менее дальнейшее наращивание объема выборки никак не повлияло на метрики. Требовались радикальные меры.

Если увеличение выборки не приносит плодов, значит, архитектура нейросети излишне примитивна. Я увеличил число слоев в классификаторе с 1 до 2 — и это дало новые дивиденды. И пусть в цифровом выражении прирост с 0.721 до 0.758 кажется скромным, в полевых условиях активация срабатывала практически безотказно, изредка требуя повторить фразу.

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

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

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

import os, random, wave, urllib.request
from piper import PiperVoice, SynthesisConfig
RU_VOICES = ['irina', 'denis', 'dmitri', 'ruslan']
RU_VOICE_DIR = '/content/piper_ru_voices'
os.makedirs(RU_VOICE_DIR, exist_ok=True)
for name in RU_VOICES:
   base = f'https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/{name}/medium/ru_RU-{name}-medium.onnx'
   for suffix in ['', '.json']:
       dest = f'{RU_VOICE_DIR}/ru_RU-{name}-medium.onnx{suffix}'
       if not os.path.exists(dest):
           urllib.request.urlretrieve(base + suffix, dest)
           print(f'  downloaded {os.path.basename(dest)}')
PHRASE = 'Пип-бой'
CLIPS_PER_VOICE = 250   # старт, проверить гипотезу; если поможет — увеличить
TRAIN_FRACTION = 0.67   # тот же делёж train/test, что у английских клипов
pos_train_dir = cfg['positive_clips_train_dir']
pos_test_dir  = cfg['positive_clips_test_dir']
length_scales = [0.85, 1.0, 1.15, 1.3]
noise_scales  = [0.5, 0.667, 0.8]
for name in RU_VOICES:
   voice = PiperVoice.load(f'{RU_VOICE_DIR}/ru_RU-{name}-medium.onnx')
   n_train = int(CLIPS_PER_VOICE * TRAIN_FRACTION)
   for i in range(CLIPS_PER_VOICE):
       syn_config = SynthesisConfig(
           length_scale=random.choice(length_scales),
           noise_scale=random.choice(noise_scales),
       )
       target_dir = pos_train_dir if i < n_train else pos_test_dir
       with wave.open(f'{target_dir}/ru_{name}_{i}.wav', 'wb') as wav_file:
           voice.synthesize_wav(PHRASE, wav_file, syn_config=syn_config)
   print(f'  {name}: {CLIPS_PER_VOICE} clips ({n_train} train / {CLIPS_PER_VOICE-n_train} test)')

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

Добившись стабильного результата, я переключился на речевой парсер.

Распознавание речи

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

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

А вот с командами возникли трудности: начальное слово фразы систематически «проглатывалось». Тестируя команду «легкое ранение», в логах я наблюдал следующую картину:

08-29 00:04:43.162 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.196 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.245 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.305 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.334 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.378 D/VoiceCommand( 8105): partial: "я ранения"
08-29 00:04:43.482 D/VoiceCommand( 8105): final: "пока я ранения"
08-29 00:04:57.894 D/VoiceJournal( 8105): partial: "хранения"
08-29 00:04:58.060 D/VoiceJournal( 8105): partial: "хранения"
08-29 00:04:58.278 D/VoiceJournal( 8105): partial: "хранения"
08-29 00:04:58.466 D/VoiceJournal( 8105): partial: "хранения"
08-29 00:04:58.643 D/VoiceJournal( 8105): partial: "хранения"
08-29 00:04:58.876 D/VoiceJournal( 8105): final: "хранения"

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

Вот как выглядел первоначальный вариант кода:

fun startCommandRecognition() {
   synchronized(commandLock) {
       val loadedModel = model ?: return
       commandRecognizer?.close()
       commandRecognizer = Recognizer(loadedModel, SAMPLE_RATE)
   }
}
fun stopCommandRecognition() {
   synchronized(commandLock) {
       commandRecognizer?.close()
       commandRecognizer = null
   }
}

Каждый вызов startCommandRecognition() принудительно завершал предыдущий экземпляр Recognizer и создавал новый. А метод stopCommandRecognition() уничтожал его повторно. Жизненный цикл строился по схеме «один инстанс на одну команду».

Исправление, решившее проблему, выглядело следующим образом:

fun startCommandRecognition() {
   synchronized(commandLock) {
       if (commandRecognizer == null) {
           val loadedModel = model ?: return
           commandRecognizer = Recognizer(loadedModel, SAMPLE_RATE)
       }
   }
}
fun stopCommandRecognition() {}
private fun closeCommandRecognition() {
   synchronized(commandLock) {
       commandRecognizer?.close()
       commandRecognizer = null
   }
}
fun release() {
   stopListening()
   closeCommandRecognition()
   model?.close()
   model = null
}

Теперь метод startCommandRecognition() инициализирует распознаватель только в том случае, если он еще не создан (commandRecognizer == null), в остальных сценариях переиспользуя существующий объект. Метод stopCommandRecognition() стал пустышкой (no-op), а полное освобождение ресурсов (closeCommandRecognition()) вызывается единожды при завершении работы приложения (release()).

Обоснование такого поведения можно найти в первоисточнике:

Kaldi: OnlineCmvn Class Reference — официальная документация по онлайн-нормализации признаков в движке Kaldi, лежащем в основе Vosk. Параметры вроде speaker_frames/global_frames описывают механизм оптимизации, который «улучшает оценку для первых секунд каждого высказывания». Иными словами, начальные миллисекунды работы свежесозданного парсера обрабатываются по еще не стабилизированным статистическим данным.

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

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

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов