Steven
Steven5 мин. чтения

Баг с наушниками-вкладышами: когда собеседник исчезал из нашей расшифровки

Двухканальная расшифровка работала безупречно на динамиках ноутбука и на полноразмерных наушниках. Потом кто-то вставил наушники-вкладыши — и человек, с которым он говорил, просто исчезал из расшифровки, потому что Chromium молча заглушал наше аудио в тот миг, когда собеседник начинал говорить. Одиннадцать исправлений провалились. Двенадцатое изменило всю архитектуру.

Инженерия
Аудио
Расшифровка
Релизы GeekBye
Баг с наушниками-вкладышами: когда собеседник исчезал из нашей расшифровки

GeekBye расшифровывает две стороны разговора: ваш микрофон на одном канале, звук собеседника (то, что проигрывает ваш компьютер) на другом. Это работало безупречно на динамиках ноутбука. Работало на полноразмерных наушниках. Потом пользователь вставил наушники-вкладыши — и человек, с которым он говорил, исчезал из расшифровки. Не искажённый. Не приписанный не тому. Пропал.

Это история GeekBye v1.6.12, бага, потребовавшего одиннадцати провалившихся исправлений, и момента, когда мы поняли, что боремся с браузером вместо того, чтобы работать вместе с ним.

Дорожка, которая была живой и пустой

Симптом был жутковатым. Вы начинали встречу, всё выглядело нормально, вы произносили одно слово — и с этого мгновения аудиоканал собеседника уходил в чистую цифровую тишину. Мы это измерили: сигнал системного звука сидел на нормальном уровне энергии (RMS около 0.03–0.06), и в тот миг, когда микрофон улавливал ваш голос, он падал до 0.0000 и оставался там до конца сессии.

Вот часть, которая стоила нам дней. Каждый API браузера настаивал, что аудиодорожка совершенно исправна. readyState: live. enabled: true. muted: false. По каждому флагу статуса, который выставляла платформа, поток был в порядке. Мёртвыми были сэмплы — живая труба, несущая одни нули. Мы долго не верили собственному измерителю энергии, потому что официальная версия гласила, что дорожка работает.

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

Одиннадцать исправлений, несколько из них хуже

Сначала мы атаковали это очевидными способами и записали каждую попытку. Отключить эхоподавление, шумоподавление и автоусиление на обоих потоках. Запускать оба потока последовательно с задержкой вместо одновременного старта. Дать каждому потоку свою частоту дискретизации. Добавить тихий узел усиления. Прогнать всё через один общий аудиоконтекст. Перейти на AudioWorklet. Спуститься вплоть до сырого процессора дорожки WebCodecs.

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

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

Исправление: дай браузеру один поток, а не два

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

Был и приятный бонус. Два потока означали два WebSocket клиент-к-бэкенду и два соединения бэкенд-к-провайдеру; один смикшированный поток свернул это до одного и одного. Исправление бага с наушниками-вкладышами вдвое урезало нашу стоимость расшифровки как побочный эффект.

Но у моно-микширования есть очевидная проблема: если ты сливаешь обоих людей в один канал, как всё ещё понять, кто что сказал? Ты выбросил ровно то разделение, которое об этом сообщало.

Пересборка «кто говорил» из энергии

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

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

Этот proof-of-concept — моно-микширование плюс основанное на энергии распознавание голоса с учётом эха — предок системы атрибуции, на которой GeekBye работает сегодня. Строительные леса выдрали в том же релизе, как только они себя оправдали, а математика энергии позже затвердела в как следует протестированный модуль. Но v1.6.12 — это место, где идея родилась, под давлением, благодаря пользователю с парой наушников-вкладышей.

Три вещи, которым научил баг с наушниками-вкладышами

  1. Исправный дескриптор может нести мёртвые данные. Всё расследование держалось на доверии нашему измерителю энергии выше флагов статуса платформы — live, enabled, unmuted, все они лгали, пока сэмплы были нулями. Следи за полезной нагрузкой, а не за самоотчётом API. Если поток заявляет, что он в порядке, измерь, так ли это на самом деле.
  2. Когда платформа воюет с твоей архитектурой, меняй архитектуру — не платформу. Мы не могли переспорить защиту от обратной связи Chromium, и каждая попытка отключить её по краям проваливалась или била рикошетом. Устранение наблюдаемого условия — два потока, становящиеся одним — заставило всю проблему исчезнуть. Почти всегда дешевле убрать триггер, чем выиграть бой с браузером.
  3. Когда сворачиваешь каналы ради надёжности, ты обязан пересобрать то, что уничтожил. Микширование в моно убило разделение каналов, несшее «кто говорил». Эту информацию пришлось вывести заново из энергии до микса и порогов с учётом эха. Компенсирующая логика — не довесок к исправлению; она и есть половина исправления.

Это четвёртая глава истории о надёжности, которая становится GeekBye v2. За предыдущей главой смотри оборванное соединение не должно ронять всё твоё приложение (v1.6.8); за переписыванием конвейера, на котором строилась эта работа со звуком, мы удалили 5000 строк аудиокода (v1.6.0); а за всей дугой, анатомию доведения софта до совершенства.

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

Тишина была несущей
Steven
Steven7 мин. чтения

Тишина была несущей

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

Инженерия
Аудио
Надёжность
Три глагола, что держат Web Audio живым
Steven
Steven8 мин. чтения

Три глагола, что держат Web Audio живым

Два точечных релиза GeekBye, разделённые двумя месяцами и в двух разных файлах, преподали нашему аудиокоду один и тот же урок с противоположных концов: перестань обращаться с браузерным AudioContext как с одноразовым. Один релиз научился делать resume() контексту, который macOS тихо приостановил посреди записи; другой научился suspend() вместо close(), чтобы идущие подряд сессии перестали врезаться в навязанный Chromium потолок примерно в шесть контекстов. Resume, suspend, close — вот и весь сюжет.

Инженерия
Аудио
Десктоп
Отличить звонок от открытого приложения
Steven
Steven8 мин. чтения

Отличить звонок от открытого приложения

GeekBye умеет заметить, что ты присоединился к видеовстрече, и предложить её записать. Обнаружение оказывается лёгкой половиной — нативный Swift-бинарник читает заголовки окон каждые десять секунд. Трудная половина — точность: не срабатывать, когда Zoom просто открыт, не предлагать записать встречу, которую ты уже записываешь, и не заглушать микрофон в звонке, в котором ты на самом деле сидишь. Три релиза, и каждый из них — предохранитель, которому пришлось научиться не побеждать самого себя.

Инженерия
macOS
Десктоп