
Баг с наушниками-вкладышами: когда собеседник исчезал из нашей расшифровки
Двухканальная расшифровка работала безупречно на динамиках ноутбука и на полноразмерных наушниках. Потом кто-то вставил наушники-вкладыши — и человек, с которым он говорил, просто исчезал из расшифровки, потому что Chromium молча заглушал наше аудио в тот миг, когда собеседник начинал говорить. Одиннадцать исправлений провалились. Двенадцатое изменило всю архитектуру.
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 — это место, где идея родилась, под давлением, благодаря пользователю с парой наушников-вкладышей.
Три вещи, которым научил баг с наушниками-вкладышами
- Исправный дескриптор может нести мёртвые данные. Всё расследование держалось на доверии нашему измерителю энергии выше флагов статуса платформы —
live,enabled,unmuted, все они лгали, пока сэмплы были нулями. Следи за полезной нагрузкой, а не за самоотчётом API. Если поток заявляет, что он в порядке, измерь, так ли это на самом деле. - Когда платформа воюет с твоей архитектурой, меняй архитектуру — не платформу. Мы не могли переспорить защиту от обратной связи Chromium, и каждая попытка отключить её по краям проваливалась или била рикошетом. Устранение наблюдаемого условия — два потока, становящиеся одним — заставило всю проблему исчезнуть. Почти всегда дешевле убрать триггер, чем выиграть бой с браузером.
- Когда сворачиваешь каналы ради надёжности, ты обязан пересобрать то, что уничтожил. Микширование в моно убило разделение каналов, несшее «кто говорил». Эту информацию пришлось вывести заново из энергии до микса и порогов с учётом эха. Компенсирующая логика — не довесок к исправлению; она и есть половина исправления.
Это четвёртая глава истории о надёжности, которая становится GeekBye v2. За предыдущей главой смотри оборванное соединение не должно ронять всё твоё приложение (v1.6.8); за переписыванием конвейера, на котором строилась эта работа со звуком, мы удалили 5000 строк аудиокода (v1.6.0); а за всей дугой, анатомию доведения софта до совершенства.