Нас пятеро: PAPA DAN, Alexme, Mihanz, San4o и A1ex. Мы друзья и по совместительству команда в Escape from Tarkov: Arena. В такой игре голосовая связь нужна не «для поболтать»: короткое «слева», «двое на точке» или «перезаряжаюсь» иногда живет меньше секунды.

После блокировки Discord в России 8 октября 2024 года мы какое-то время пользовались обходным способом. Потом перестал работать и он. Мы начали искать российскую систему для командного общения, но поиски не увенчались успехом: нам не удалось найти вариант, который одновременно устраивал бы нашу пятерку по простоте входа, качеству голоса и наличию компактного игрового оверлея.

Тогда мы решили сделать свою говорилку. Так появился БОЛТУН.

Сразу обозначу конфликт интересов: это не независимый обзор рынка. Я, PAPA DAN, разрабатываю БОЛТУН; Alexme стал главным тестировщиком, а Mihanz, San4o и A1ex участвовали в живых командных проверках и предлагали изменения. Эта статья не о том, как мы с первого раза написали «убийцу Discord». Она о более полезной вещи: как совершенно нормальный голосовой кодек оказался невиновен, а звук рвался из-за нашего собственного логирования.

Что мы хотели получить

Первый список требований был коротким:

  • без регистрации и длинной настройки;

  • создал комнату, друзья увидели ее и вошли;

  • до десяти участников, необязательный PIN;

  • отдельная громкость каждого собеседника;

  • push-to-talk и обычная активация голосом;

  • маленький оверлей поверх игры;

  • Windows как основная платформа, затем Android и браузер.

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

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

Локальная запись нас обманула

На первых тестах исходящий диагностический WAV был идеальным. Микрофон захватывался без провалов, слова не обрезались, темп речи не менялся. Логичный, но неправильный вывод звучал так: «Раз запись чистая, с аудио все хорошо».

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

микрофон -> локальная диагностическая запись -> кодирование -> очередь отправки
           ^ здесь было хорошо                 -> сеть -> очередь приема
                                                     -> декодирование -> динамики

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

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

Самый полезный тест стоил ноль рублей

Мы подключались к одной комнате и считали друг другу от одного до десяти. В одном из прогонов слушатель стабильно не услышал: 2, 4, 6, 8, 10.

Такой тест примитивнее любого спектроанализатора, зато сразу различает три класса проблем:

  • пропавшее начало или окончание слова указывает на VAD либо push-to-talk;

  • исчезающие целые фрагменты похожи на потерю или выбрасывание кадров;

  • растягивающаяся речь означает, что воспроизведение не успевает за поступлением данных и очередь растет.

Друзья формулировали еще точнее: «раз в две-три секунды выкусывает кусок». В тот момент мы еще не поняли, насколько важны слова «раз в три секунды».

Мы перебрали почти все очевидные причины

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

Один участник говорил, второй записывал именно пришедший звук, остальные отмечали момент дефекта. Затем мы сопоставляли запись с voice.log, p2p-voice.log, номерами кадров и временем их прихода. Иногда новая сборка действительно улучшала звук, но одновременно добавляла задержку. Иногда проблема исчезала в локальном тесте и возвращалась в общем разговоре. А некоторые «ускорения» делали речь только хуже.

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

1. Увеличили джиттер-буфер

Сначала приемный буфер вырос примерно с 40 до 100 мс, затем до 160 мс. Мы расширили очередь воспроизведения и добавили маскировку единичных потерянных Opus-кадров.

Стало немного ровнее, но периодический дефект остался. Большой буфер умеет пережить сетевой джиттер, однако не исправляет источник, который сам останавливает аудиопуть. Зато легко добавляет задержку.

2. Перевели воспроизведение и захват на WASAPI

Старое воспроизведение через waveOut заменили на WASAPI shared render, затем перевели на WASAPI и захват микрофона. Для неподдержанных устройств оставили резервный путь.

Это было полезное изменение само по себе. WASAPI дает приложению понятную модель работы с буфером конечного аудиоустройства: приложение периодически читает кадры из capture-буфера и пишет их в render-буфер. Именно так этот механизм описывает документация Microsoft.

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

3. Начали «лечить» очередь отправки

Дальше появился pacer: захваченные кадры складывались в ограниченную очередь и отправлялись каждые 20 мс. Идея была разумной: убрать сетевые всплески. Реализация оказалась слишком агрессивной.

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

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

4. Дали тестировщикам ползунки от 1 до 1000 мс

В настройках появились два изменяемых на лету параметра: приемный буфер и размер голосового пакета. Мы перебирали комбинации прямо во время разговора.

На нашей связи лучше всего звучали примерно 100 мс приемного буфера и 60 мс аудио в пакете. Затем захотелось проверить 80 мс, и тут выяснился важный нюанс: отдельный кадр Opus может иметь длительность 2,5, 5, 10, 20, 40 или 60 мс. Более длинный пакет нужно собирать из нескольких допустимых кадров. В RFC 6716 отдельно отмечено, что увеличение длительности снижает накладные расходы, но повышает задержку и цену потери одного пакета; 20 мс названы хорошим выбором для большинства применений.

Мы сделали собственный пакетный формат TVO2. Для 80 мс БОЛТУН делит исходный PCM на два допустимых Opus-подкадра длительностью 60 + 20 мс, отдельно кодирует их и складывает в одну оболочку с общим числом сэмплов, количеством частей и длиной каждой части. Приемник последовательно декодирует подкадры и снова получает непрерывные 80 мс звука. Мы также научили рендер дописывать остаток, если устройство не могло принять весь результат сразу.

С точки зрения сети это собственный 80-миллисекундный аудиоформат БОЛТУНа поверх Opus. С точки зрения теории кодирования это не новый кодек: сжатие каждой части по-прежнему выполняет Opus. Звук менялся, а загадочный провал продолжал возвращаться.

Сегодня эти экспериментальные ползунки скрыты от обычного пользователя. Значения по умолчанию снова консервативны: 20 мс на пакет и 60 мс приемного буфера.

5. Поменяли кодек

В тестовую сборку добавили переключение между Opus, IMA ADPCM и несжатым PCM без перезапуска клиента.

Это был диагностический, а не продуктовый интерфейс. Логика простая: если дефект с периодом около трех секунд воспроизводится даже на PCM, значит, искать «плохой Opus» бессмысленно.

Так и произошло. Характерный ритм пережил смену кодека. Opus был оправдан.

Но этот этап не пропал зря. Мы разобрали весь кодековый путь по частям: допустимые длительности Opus-кадров, частоту дискретизации, битрейт, упаковку нескольких кадров, PLC, преобразование PCM и совместимость декодеров на Windows, Android и в браузере. Затем написали собственный слой упаковки поверх стандартных кодеков:

  • TVO1 - одиночный Opus-кадр;

  • TVO2 - пакет из нескольких допустимых Opus-подкадров;

  • TVA1 - совместимый IMA ADPCM-кадр;

  • TVR1 - бинарная оболочка серверного relay с комнатой, отправителем, sequence number и временем захвата.

Здесь важно не подменять термины. БОЛТУН не изобрел новый математический аудиокодек: внутри по-прежнему работают проверенные Opus и IMA ADPCM. Наша собственная технология - это формат BOLTUN Voice и фирменный голосовой стек вокруг него: сетевые кадры, пакетирование, выбор транспорта, ограниченные очереди, джиттер-буфер, PLC, ресемплинг и правила совместимости между платформами. Именно этот слой определяет поведение связи не меньше, чем название кодека.

6. Разделили серверный relay и прямой UDP

Чтобы проверить подозрение на слабый сервер, мы сделали три режима транспорта: автоматический, только прямой UDP и только серверный relay. В логи добавили номера последовательности, интервалы прихода и события packet-loss concealment.

Прямой UDP помог отделить сетевой путь от остального аудиотракта, но не стал универсальным решением: самодельный hole punching со STUN-кандидатами проходит не через каждый NAT и CGNAT. Без полноценного ICE/TURN прямое соединение между двумя Windows-машинами нельзя гарантировать. Поэтому автоматический режим использует UDP, когда маршрут действительно поднят, и серверный relay как запасной путь. Android и веб-клиент пока работают через relay.

И тут у нас наконец появилась улика, которая совпала с симптомом математически.

Пятьдесят кадров по 60 мс

В голосовом коде была диагностическая запись обычного события sent/received voice frame каждые 50 кадров. Файл открывался и записывался прямо на горячем пути аудио.

Во время теста размер пакета был 60 мс:

50 кадров x 60 мс = 3000 мс

Ровно те самые три секунды.

Упрощенно старое поведение можно представить так:

if (++frames % 50 == 0) {
    std::ofstream out("logs/voice.log", std::ios::app);
    out << "sent voice frame " << sequence << std::endl;
}

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

Исправленный вариант держит поток открытым, записывает обычную телеметрию в десять раз реже и принудительно сбрасывает только важные события либо небольшую пачку строк:

void appendVoiceLog(const std::string& line) {
    static std::ofstream out("logs/voice.log", std::ios::app);
    static int pendingLines = 0;

    out << line << '\n';
    ++pendingLines;

    const bool important = containsGapOrFailure(line);
    if (important || pendingLines >= 20) {
        out.flush();
        pendingLines = 0;
    }
}

if (frames <= 3 || frames % 500 == 0) {
    appendVoiceLog(frameDescription);
}

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

Как тракт устроен сейчас

На момент написания статьи основной Windows-путь выглядит так:

┌──────────────┐
│ WASAPI input │
└──────┬───────┘
       v
 преобразование в 16 кГц mono
       |
 echo cancellation -> RNNoise -> VAD / push-to-talk
       |
 Opus VOIP, 20 мс, 24 кбит/с
       |
       +---- direct UDP между Windows, если NAT пропустил
       |
       +---- binary WebSocket relay как основной/fallback-путь
       |
 sequence number -> per-peer jitter buffer -> Opus PLC
       |
 персональная громкость -> микшер -> WASAPI output

Здесь нет волшебного компонента. Устойчивость складывается из ограничений на каждом участке:

  • голосовой кадр имеет номер последовательности;

  • слишком поздние и повторные кадры не воспроизводятся второй раз;

  • для каждого собеседника работает отдельный джиттер-буфер;

  • единичную потерю после реального Opus-кадра маскирует PLC;

  • очередь relay на сервере ограничена 16 кадрами на клиента;

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

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

Android и браузер добавили свои сюрпризы

Когда Windows-клиенты начали разговаривать стабильно, мы добавили Android и веб. Слово «тот же Opus» не означает «тот же аудиопуть».

Android мог декодировать 16 кГц в 48 кГц

Системный декодер Android на некоторых устройствах возвращал PCM с частотой 48 кГц, хотя голос был закодирован из 16 кГц. Если считать эти сэмплы шестнадцатикилогерцовыми, двадцатимиллисекундный фрагмент превращается в гораздо более длинный. Отсюда замедленная речь и растущая очередь.

Исправление - читать реальный выходной формат декодера и явно ресемплировать 48 -> 16 кГц, сохраняя длительность кадра. Сетевую запись пришлось отделить от аудиопотока и вынести в асинхронный writer с ограниченной очередью.

Веб зависит от возможностей браузера

Веб-клиент использует Opus через WebCodecs там, где API поддерживается, и совместимый ADPCM fallback в остальных случаях. Сам WebCodecs предоставляет JavaScript-интерфейс к существующим кодекам, но не обещает, что браузер поддержит каждый кодек на каждой платформе. Для Opus существует отдельная регистрация WebCodecs.

Есть и системное ограничение: мобильный браузер может остановить микрофон и JavaScript после сворачивания или блокировки экрана. Screen Wake Lock помогает не погасить активную страницу, но браузер снимает блокировку, когда документ перестает быть видимым, и не превращает сайт в полноценный Android foreground service. Поэтому для надежного фонового разговора нужен нативный APK.

«Улучшайзеры» иногда ухудшают

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

Даже меню может сломать голос

Еще один живой тест дал почти абсурдный отчет: «Пока тащу окно настроек, звук пропадает». Позже похожее происходило, пока открыто контекстное меню в трее.

Причина была архитектурной. Обработка аудио и сети частично зависела от обычного UI message loop. Модальное меню или drag-loop забирали управление, и голос переставал регулярно прокачиваться.

После этого правило стало простым: окно можно блокировать, перетаскивать и перекрашивать сколько угодно; аудиопоток и сетевой writer не должны этого замечать. Красивый интерфейс не имеет права быть частью real-time scheduler.

Что из этого выросло

Сейчас БОЛТУН - это не макет и не лабораторный WAV:

  • общие комнаты для Windows, Android и веба;

  • до десяти человек, необязательный четырехсимвольный PIN;

  • до 100 одновременно активных комнат на координаторе;

  • вход без регистрации;

  • персональная громкость и mute участников;

  • push-to-talk, обработка звука и выбор устройства;

  • Windows-оверлей поверх игры;

  • автоматическая проверка версий;

  • отдельный закрытый тестовый контур до выпуска обновлений;

  • мониторинг комнат, платформ, версий и состояния relay;

  • нагрузочный клиент, способный создавать до 1000 виртуальных подключений для будущих ступенчатых испытаний.

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

Ограничения тоже есть. Прямой UDP пока доступен только между Windows-клиентами и зависит от типа NAT; межплатформенный голос идет через сервер. Фоновый разговор в мобильном браузере ограничен политикой Android и браузера. Android-клиент по-прежнему требует больше испытаний на разных производителях устройств, чем любой наш настольный тест.

Семь выводов, которые мы забрали с собой

  1. Ищите период в собственном коде. Если сбой повторяется каждые три секунды, сначала найдите все таймеры, счетчики, flush, health-check и сборщики статистики с тем же периодом.

  2. Никакого дискового I/O на горячем аудиопути. Даже «одна строка иногда» может оказаться вашим самым стабильным источником нестабильности.

  3. Идеальный исходящий WAV проверяет только половину системы. Нужны записи и метрики после сети, декодера и приемного буфера.

  4. Смена кодека - это эксперимент, а не вера. PCM помог нам исключить Opus, но сам по себе не был решением.

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

  6. Пользовательский интерфейс нельзя использовать как аудиопланировщик. Модальное окно не должно останавливать микрофон.

  7. Пять друзей - неплохой стенд, если дать им точный сценарий. Счет до десяти, одинаковая фраза, две записи и номера кадров оказались полезнее часа споров о том, «плохой ли Opus».

Мы начинали с желания спокойно играть впятером после того, как привычный способ связи исчез. В итоге получили C++-клиент под Windows, Go-координатор, Android-приложение, веб-клиент, собственный межплатформенный голосовой стек и довольно дорогой урок о том, что диагностический код тоже является частью real-time системы.

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

Проект: boltun.org

Команда: PAPA DAN - разработчик; Alexme - главный тестировщик; Mihanz, San4o и A1ex - участники командных тестов, обратной связи и той самой пятерки из Tarkov Arena.