Я делаю работающую реплику пип‑боя для ролевых игр по вселенной Fallout. Сама по себе эта задача — довольно тривиальная с инженерной точки зрения, но и в ней есть кое‑что, что, возможно, заинтересует пытливые умы. Расскажу, как научил телефон распознавать вейк‑слово, с матюгами заставил правильно работать речевой парсер и сплясал чечётку на полном ассортименте садовых инструментов, связанных с голосовым управлением вещами.
Дисклеймер. Я не кодер, а всего лишь технический писатель. А по совместительству — фанат Fallout, у которого есть друг‑девопс с 3d‑принтером, куча сомнительных идей, подписка на Claude и руки, растущие из правильного места. Того самого, в котором шило.
Предыстория
Впервые попав на ролевую игру по Фоллауту, я офигел. Один затейник пригнал мотоось и собрал из неё машину для торгового каравана. Другой привёз собственную радиостанцию. Третий — вывез целую мастерскую, чтобы играть в крафтера.
На весь игровой полигон мастера (так называют людей, делающих игру) провели электричество — и игроки могли, к примеру, покопавшись в электрощитке, отрубить свет на какой‑нибудь локации. Всё происходило по‑настоящему: хочешь сделать что‑то по игре — сделай это по жизни. Как поётся в одной песне, мы не из дурдома, мы ролевики.
Но была одна деталь, которая меня печалила: полное отсутствие робототехники. На, мать его, Фоллауте. Том самом, в котором я, будучи ещё школьником, пытался десинхронизировать нептуниевую крыльчатку.

Максимум, который можно было сделать на игре по части робототехники, — это найти терминал, поиграть на нём в мини‑игру по взлому пароля и, в случае успеха, почитать пару файлов текстового инфодампа от мастеров. Но где же взлом электронных замков? Где хакинг охранных контуров и управление автоматическими турелями? Где диалоги с поломанными роботами? Так я решил добавить в ролевые игры по Фоллауту немного робототехники. И начал с малого — с полностью работающего пип‑боя.
Концепция устройства. Моё устройство должно читать голодиски, показывать карту полигона с GPS‑навигацией, ловить игровое радио, иметь встроенный счётчик Гейгера, трещащий от игровой радиации, плюс ещё ряд функций, связанных с механиками полигонной ролевой игры.
Телефон на Андроиде вставляется в корпус и выполняет роль управляющего устройства и экрана. С периферией общается по BLE. Интернета на полигоне часто нет, поэтому всё, что я накручу, должно работать в офлайн‑режиме.
Идея голосового управления родилась не сразу, а из попыток решения прикладной задачи. А именно: на полигоне стреляют страйкбольными шарами, и телефон надо защитить.
Маленький шарик, передающий поверхности экрана 3 джоуля кинетической энергии на площади, сопоставимой с точкой, превращает оную поверхность в фарш, каким бы прочным ни было защитное стекло. Поверхность экрана надо закрыть чем‑то упругим, а значит, с тачскрином можно попрощаться.
На периферии есть кнопочки и крутиляторы, но их мало, и тыкать их долго. А если от пип‑боя требуется, например, зафиксировать ранение и поставить таймер на кровотечение — никто не будет стоять и тупить в тумблеры, когда надо падать на землю и стонать от боли.
Архитектура
Фича голосового ввода строится из трёх компонентов:
Движок вейк‑слова. Этот блок мониторит входящий поток микрофона, узнаёт целевое слово и активизируетя на приём команды. Для этих целей есть платный Porcupine, но у нас‑то бесплатный колхоз, поэтому я выбрал openWakeWord — опенсорсный фреймворк для тренировки собственных вейк‑слов.
Движок распознавания голоса. Тут я остановился на ещё одном опенсорсном кандидате — Vosk. Он офлайновый и имеет относительно компактную модель для Андроид‑устройств — примерно 50 Мб на русскую модель и ещё столько же на нативные библиотеки. Чтобы не сильно раскармливать APK, я сделал в настройках пип‑боя интерфейс для ручного импорта модели.
Собственный код приложения, отвечающий за то, что именно приложение должно сделать, получив команду.
Тренировка вейк‑слова
Суть тренировки проста: скрипт на python генерит заданное количество семплов произнесения вейк‑слова разными голосами, с разными фоновыми шумами, а потом заставляет модель всё это добро слушать, наказывая за ложные срабатывания и сохраняя наиболее удачные выборки.
Для тренировки я использовал Google Colab — бесплатная версия предоставляет ограниченный доступ к серверу с GPU, которого, несмотря на предупреждения, что тренировка модели проходит долго, с головой хватило для моей цели.
В исходном варианте фреймворка тренировочная выборка включала 2000 позитивных английских семплов и 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 семплов, попутно подняв штраф за ложные срабатывания — и здесь меня ждал первый качественный скачок: recall 0.721, accuracy 0.850. Однако радость была недолгой. Повторное увеличение выборки никак не отразилось на целевых показателях. Пришлось искать новые способы.
Если при увеличении тренировочной выборки модель не выдаёт лучших показателей — это примета того, что наша модель слишком груба. Я увеличил количество блоков в архитектуре классификатора с 1 до 2 — и получил новое продвижение. И если на бумаге разница между 0.721 и 0.758 невелика, то на реальном устройстве распознание вейк‑слова срабатывало уже почти каждый раз — изредка приходилось повторить.
Но здесь я столкнулся с забавным побочным эффектом, который был предопределён дизайном эксперимента. Я тренировал модель на английских семплах, и чем лучше она усваивала именно английское произношение, тем яснее становилось, что средний русский ролевик на полигоне будет чувствовать себя как шотландцы в лифте. Пора было переходить на русские слова.
И теперь настало время объяснить, почему я сразу не стал генерить русские семплы. OpenWakeWord просто не поддерживает тренировку на русском языке — и вряд ли когда‑то будет. Можно, конечно, нагенерить семплы самому — но количество русских голосовых моделей ограничено, не получится создать того же разнообразия голосов и шумовых эффектов на фоне.
К счастью, «пип‑бой» — английсое слово, поэтому нам достаточно просто научить модель понимать русский акцент. Я сделал небольшую инъекцию русских семплов в тренировочную выборку — 1000 штук вдобавок к английским:
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)')
И получилось! Модель отлично распознаёт русский акцент. На бумаге частота узнаваний снизилась, но это вполне ожидаемо — она ведь считалась для английского слова. Отдельно частоту узнавания русского акцента никто не считал — а она, судя по тестам на устройстве, выросла на порядок.
На этом я удовлетворился и перешёл к голосовому парсеру.
Распознание голоса
Распознание голоса нужно в моём устройстве для двух связанных целей. Это голосовые команды, о которых речь шла до сих пор, и плюс к ним — голосовой ввод журнала. У пип‑боя ведь нет своей клавиатуры, а использование системной клавиатуры Андроида выбивало бы из погружения, да и технически недоступно, раз у нас нет тачскрина.
С голосовым вводом журнала всё прошло отлично. Малая модель Vosk для Андроид‑устройств достаточно хорошо распознаёт бытовую речь. Можно даже надиктовывать ей стихи Бродского, получая приемлемый уровень ошибок.
А вот с голосовыми командами дело не задалось: первое слово команды регулярно съедалось. На тестовой команде «лёгкое ранение» я видел примерно такой лог:
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, затем создавал новый — Recognizer(loadedModel, SAMPLE_RATE). А stopCommandRecognition() снова его закрывал. То есть жизненный цикл объекта был «один Recognizerна одну попытку команды», раз за разом.
А вот фикс, устранивший проблему:
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() создаёт Recognizer, только если его ещё нет вообще (commandRecognizer == null) — во всех остальных случаях он переиспользует тот же самый объект. stopCommandRecognition() превратился в no‑op. Реальное закрытие (closeCommandRecognition()) теперь вызывается только один раз — из release().
Документация, объясняющая решение:
Kaldi: OnlineCmvn Class Reference — официальная документация онлайн‑нормализации признаков в Kaldi (движок, на котором построен Vosk). Параметры speaker_frames/global_frames там прямо описаны как механизм, который используется «to improve the estimate for the first few seconds of each utterance» — то есть первые доли секунды свежесозданного распознавателя по определению обрабатываются на ещё не устоявшейся статистике нормализации.
На выходе — чёткое срабатывание голосовой команды. «Пип‑бой, лёгкое ранение» запускает цепочку таймеров, за время которых игрок должен успеть перевязаться и получить медицинскую помощь, пока состояние здоровья не начало эскалироваться.
И это маленький шажок к реплике Мистера Хенди, которая сможет переругиваться матом с игроками.

