В прошлой статье я рассказывал, как мы строили русскоязычный голосовой стек для китайского гуманоида Walker Tienkung TK2301 в нашей лаборатории 361.Robotics — своё распознавание речи, синтез и логику диалога. Там я обещал отдельно разобрать, откуда этот стек берёт звук: штатный микрофон робота для живой работы не годится, и мы завели внешний. Это та самая обещанная история — и она оказалась заметно длиннее, чем ожидалось.

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

Решение напрашивалось само: вынести микрофон наружу, на радиоканал, чтобы оператор мог держать его в руке или на воротнике и свободно перемещаться вокруг робота. Звучит как рутинная задача — воткнул USB, написал pip install, готово. Мы тоже так думали. Реальность заняла два полных дня, и с каждым шагом всё оказывалось не так просто, как выглядело на первый взгляд.

Дальше — история о том, как «сменить источник звука» превратилось в разбор того, сколько неочевидных состояний прячется за фразой «микрофон отвалился». Будет полезно тем, кто заводит нестандартную периферию на роботах, работает с беспроводными радиосистемами или просто любит истории, где простая на бумаге задача разматывается в клубок.

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

С чем мы имели дело на старте

У робота три бортовых компьютера. Один (на архитектуре x86) отвечает за основные движения и служит хабом для периферии — это единственная машина, у которой есть свободные USB-порты, выведенные наружу корпуса. Второй (Jetson-плата, дальше в тексте — Jetson) занимается всем «интеллектуальным» — зрением, LLM и голосом. Третий занят отдельной задачей (захватом объектов и навигацией) и в этой истории не участвует, а вот два первых — как раз главные герои.

Голосовой диалог у нас работает через собственный пайплайн (тот самый из прошлой статьи, не вендорский). Крутится он на Jetson и на входе ждёт поток аудио-чанков — а что происходит с ними дальше, распознавание и синтез, для этой истории неважно. Важно одно: у пайплайна уже был готовый интерфейс «дай мне аудио-чанки», и заменить нужно было только источник этих чанков — со встроенного массива на внешний микрофон, не трогая ничего дальше по цепочке.

Ещё одна деталь, которая пригодится дальше по тексту: у робота есть пульт дистанционного управления с рычажками и джойстиками для базового управления движением. Один из рычажков, назовём его рычажком E, мы задействовали под собственную задачу — включение и выключение голосового режима, тем более что «из коробки» он заодно включал запрограммированные движения, что тоже нам пригодилось. Поднял рычажок — робот начинает слушать, опустил — перестаёт. Это тот самый «рычажок оператора», который будет упоминаться дальше.

Требование к замене было ровно одно: тот же интерфейс на выходе (чанки PCM-звука), чтобы всё, что дальше по пайплайну, вообще не заметило подмены источника.

Почему нельзя было просто воткнуть USB?

Первая идея была самой очевидной — открыть корпус, найти, куда физически подключён встроенный микрофонный массив, и воткнуть туда внешний. Идея не пережила первую же проверку: свободные USB-порты снаружи корпуса есть только у машины, которая отвечает за движение, а голосовой пайплайн живёт на Jetson — у него порты наружу не выведены вообще. Добраться до Jetson можно было только разобрав корпус, а на такой риск для рабочей машины мы не пошли.

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

Само железо выбрали простое: беспроводной микрофон-петличку с USB-приёмником — компактная радиосистема, приёмник у которой определяется системой как обычная USB-звуковая карта. Просто и понятно, никакого специального SDK не нужно — думали мы.

Тот самый комплект в зарядном кейсе: две клипсы-передатчика, приёмник-донгл и ветрозащита. Система видит приёмник как обычную USB-звуковую карту — и именно эта «обычность» позже нас и подвела.
Тот самый комплект в зарядном кейсе: две клипсы-передатчика, приёмник-донгл и ветрозащита. Система видит приёмник как обычную USB-звуковую карту — и именно эта «обычность» позже нас и подвела.

Раз физически соединить USB нельзя, а внутренняя сеть между бортовыми компьютерами есть — решено было транслировать звук по TCP. На машине с движением, где физически сидит приёмник, подняли маленький сервис: он захватывает аудио с микрофона через стандартный Linux-инструмент записи звука и отдаёт сырой поток по TCP. На Jetson, где живёт голосовой пайплайн, — клиент, который читает этот поток и скармливает его дальше в тот же VAD/ASR-конвейер, что раньше получал звук от встроенного массива.

Диаграмма 1. Звук с приёмника передаётся по TCP на Jetson, где он подключается к тому же VAD/ASR-конвейеру, что раньше получал звук от встроенного массива.
Диаграмма 1. Звук с приёмника передаётся по TCP на Jetson, где он подключается к тому же VAD/ASR-конвейеру, что раньше получал звук от встроенного массива.

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

Как понять, что микрофон «отвалился», если ошибки нет?

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

Для голосового пайплайна это означало: оператор поднимает рычажок E, чтобы робот начал слушать, а VAD честно ждёт речь в потоке нулей — и не дожидается никогда. Робот просто молчаливо «глохнет», и понять, что произошло — микрофон выключили, он разрядился или отвалилась радиосвязь — невозможно без специальной диагностики.

Приёмник живёт в единственном USB-порту, выведенном наружу корпуса, — по соседству с кнопками питания и аварийным грибком. Это порт компьютера движения, а слушает звук другой компьютер, на другом конце корпуса. Отсюда и решение с TCP.
Приёмник живёт в единственном USB-порту, выведенном наружу корпуса, — по соседству с кнопками питания и аварийным грибком. Это порт компьютера движения, а слушает звук другой компьютер, на другом конце корпуса. Отсюда и решение с TCP.

Стало ясно: одного лишь «транслировать звук по TCP» мало, нужна отдельная логика диагностики состояния микрофонного тракта. Решили делать её на два канала одновременно: индикатор на корпусе робота (загорается определённым цветом, когда с голосовым трактом что-то не так) — для визуальной оценки состояния, и голосовая подсказка через те же встроенные динамики, что озвучивают ответы робота, — чтобы оператор сразу понимал не просто «что-то сломалось», а что именно сломалось и как это исправить. Индикатор один на все виды проблем, а подсказки должны различаться по смыслу — значит, нужно уметь различать сами проблемы, а не просто ловить факт «звука нет». Логика должна была различать три разных состояния:

  1. всё в порядке, и микрофон и USB-приёмник подключены и работают штатно;

  2. USB-приёмник физически на месте, но микрофон молчит (выключен, разряжен, вне зоны действия);

  3. сам USB-приёмник пропал из системы (кто-то выдернул его из робота).

Кейсы 2 и 3 требуют разного текста подсказки оператору — «включите микрофон» это не то же самое, что «подключите приёмник». Но снаружи, со стороны TCP-потока, оба выглядят одинаково: поток либо с нулями, либо оборвался. Как их различить?

TCP — это просто поток байт без границ сообщений. Первая идея была «воткнуть маркер-байт» в поток при потере устройства и ловить его на другой стороне. Идея не выжила первой же проверки: маркер может слиться с остатком буфера от предыдущего чтения, и что тогда получит клиент — кусок маркера пополам с кусками звука? Ненадёжно в принципе.

Решение пришло из смежной области: в профессиональных радиосистемах для концертного звука эта проблема решена десятилетия назад — при потере радиосвязи приёмник аппаратно «заглушает» (squelch) выход в цифровые нули, и приложение определяет обрыв связи по тишине, а не по отдельному сигналу. Мы повторили тот же принцип программно: сервер на машине с приёмником сам меряет громкость потока, и если несколько секунд подряд абсолютная тишина — честно закрывает TCP-соединение. Клиент видит разрыв связи и после этого отдельным HTTP-запросом спрашивает «а устройство физически на месте?» — и вот тут уже различает «молчит» от «пропал».

Диаграмма 2. Отличаем «устройство пропало» от «устройство молчит» отдельным статус-запросом после разрыва соединения по тишине.
Диаграмма 2. Отличаем «устройство пропало» от «устройство молчит» отдельным статус-запросом после разрыва соединения по тишине.

Отдельный канал для проверки статуса — не изящно, но зато надёжно: никаких допущений про порядок байт в потоке.

Почему робот путался в собственных подсказках?

Дальше начался марафон живого тестирования пяти сценариев («рычажок включён, а приёмник выключен», «приёмник включён, а микрофон выключен», «всё в порядке» и так далее — плюс переходы между ними прямо во время разговора). И тут выяснилось, что различить состояния — это полдела. Вторая половина, неожиданно оказавшаяся сложнее, — вовремя и понятно сказать о них оператору.

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

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

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

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

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

Общий знаменатель всех этих багов один: «технически верно» и «понятно оператору» — разные требования. Диагностика могла быть абсолютно точной в каждый момент времени и при этом складываться в бессмысленную для человека кашу из противоречивых объявлений.

Почему переподключение занимало десять секунд вместо двух?

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

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

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

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

Диаграмма 3. Клиент больше не хранит своё окно ожидания как отдельное число — он вычисляет его из таймаута тишины, о котором сообщает сервер.
Диаграмма 3. Клиент больше не хранит своё окно ожидания как отдельное число — он вычисляет его из таймаута тишины, о котором сообщает сервер.

Уроки одной петлички

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

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

  • придумать способ отличать «устройство физически пропало» от «устройство на месте, но молчит» — оба выглядят как обрыв соединения;

  • завести результат диагностики сразу на два канала — индикатор на корпусе для визуальной оценки и голосовую подсказку для понимания, что именно случилось и что делать;

  • несколько раз переписать логику голосовых подсказок, потому что «технически верно» и «понятно оператору» — разные требования; особенно показательным был случай, когда состояние действительно улучшилось, а фраза звучала как ухудшение;

  • найти настоящую причину задержки не в собственном коде, а в конфликте с системным сервисом, который на первый взгляд был вообще ни при чём;

  • в конце — отказаться от ручного подбора чисел в конфиге в пользу единого источника правды между двумя независимыми процессами.

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


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

А ещё интересно мнение тех, кто заводил радиосистемы в проде: как вы отличаете «передатчик уснул» от «приёмник выдернули» и ловите ли вообще этот случай? Мы пришли к squelch-по-тишине, но не уверены, что это единственный разумный путь.

Сейчас у нас в лаборатории работают четыре платформы: гуманоиды Unitree G1 EDU+Leju Kuavo 4Pro и UBTech Walker Tienkung — Embodied Intelligence, а также промышленный квадрупед Unitree B2. Мы активно экспериментируем с ними и обучаем роботов сценариям реального применения и решению бизнес-задач. О других кейсах расскажем в будущих статьях на Хабре. А пока мы их пишем, читайте наш Telegram-канал 361.Robotics о робототехнике, воплощенном ИИ и новой экономике вокруг них. 

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

Мы открыты к обмену опытом, сотрудничеству, совместной работе над интересными проектами и просто знакомству с увлеченными людьми. Хотите увидеть современных роботов воочию — напишите нам и приезжайте на экскурсию в лабораторию 361.Robotics в Москву (м. Павелецкая).