В предыдущей статье мы сравнили старый Win32-клиент БОЛТУНа с новой веткой на Tauri. Тогда всё закончилось вполне оптимистично: новый интерфейс оказался тяжелее, зато развивать его было намного удобнее. Мы решили продолжить эксперимент и проверить приложение уже не в пустом лобби, а в настоящем разговоре.

Запустили три разных клиента: Windows-приложение на Tauri, веб-версию и APK. Зашли в одну комнату и начали разговаривать.

За один вечер нашли три проблемы:

  • демонстрация экрана примерно через минуту превращалась в чёрный прямоугольник;

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

  • самое неприятное — внутри речи появился треск.

Интерфейс работал. Комната работала. Пакеты ходили. Слова можно было разобрать. Но слушать такой разговор долго не хотелось.

В этой статье разберём только эти три сбоя. Треск привёл нас к перегрузке на выходе и слишком резким границам между аудиопакетами. Автокалибровка почти минуту вмешивалась в уже начавшийся разговор. А у чёрного экрана соединение оставалось живым — останавливались сами видеокадры.

Спойлер: Tauri не добавлял треск в звук и не выключал трансляцию по таймеру. Мы сами собрали аудиограф, который мог усилить сигнал в четыре раза, не поставили финальный ограничитель, а при потере 20-миллисекундного пакета обрывали волну почти вертикально. Видеотракт при этом не умел замечать, что подключение к LiveKit ещё существует, но новые кадры уже не отправляются.

Почему мы сначала заподозрили Tauri

Старый клиент на C++ и Win32 разговаривал. После переноса интерфейса и части общей логики в Tauri голос затрещал. Самая очевидная версия звучала просто:

Значит, WebView2 плохо работает со звуком.

Но между «после» и «из-за» есть большая разница.

Desktop-клиент на Tauri и обычный браузер используют наш общий TypeScript-код голоса. На Windows этот код работает внутри WebView2. APK использует отдельный аудиодвижок на Java, но понимает тот же сетевой формат. Сервер вообще не декодирует речь — он пересылает голосовые кадры участникам комнаты.

Упрощённо путь выглядел так:

микрофон, 48 кГц
        ↓
AudioWorklet
        ↓
16 кГц, mono, кадры по 20 мс
        ↓
Opus или совместимый ADPCM fallback
        ↓
WebSocket relay
        ↓
декодер каждого участника
        ↓
персональная громкость
        ↓
общая громкость комнаты
        ↓
WebView2 / браузер / AudioTrack Android

Схема не выглядит необычной. Поэтому мы снова начали с любимых подозреваемых: Opus, слабого сервера и джиттера сети.

Вместо спора разобрали запись

Во время теста была сделана запись экрана со звуком. Мы извлекли из MP4 аудиодорожку и посмотрели на неё как на набор сэмплов, а не как на субъективное «что-то потрескивает».

Улика оказалась заметной: после декодирования встречались пики примерно до 1,41 при ожидаемом рабочем диапазоне от -1,0 до 1,0. Причём перегруз шёл сериями в нескольких местах записи.

Это сразу изменило направление поиска. Повреждённый Opus-пакет обычно не объясняет повторяющиеся участки перегруженного микса. Зато их хорошо объясняют несколько одновременно усиленных источников без общего ограничителя.

Одновременно мы проверили границы 20-миллисекундных кадров. Щелчки не повторялись строго на каждом кадре, поэтому версия «мы неправильно упаковали вообще все Opus-фреймы» тоже не сходилась. Оставалось сочетание двух эффектов:

  1. Сигнал иногда перегружался при смешивании.

  2. При пропуске или опоздании пакета возникала резкая граница между двумя кусками волны.

Оба эффекта на слух легко объединяются в одно слово — треск.

Первая причина: громкость можно было умножить на четыре

В новом клиенте есть две независимые настройки:

  • общая громкость голосового канала — до 200%;

  • персональная громкость участника — тоже до 200%.

По отдельности обе настройки выглядели нормально. Вместе они перемножались:

2,0 × 2,0 = 4,0

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

А после общего узла громкости стояло прямое подключение к устройству вывода:

participantGain
    .connect(roomGain)
    .connect(audioContext.destination);

Финального лимитера не было. WebView2 получал уже перегруженный сигнал и ограничивал его на выходе. Вершины волны срезались, и вместо голоса появлялись характерные жёсткие призвуки.

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

Как поставили ограничитель

В вебе и Tauri после общей громкости появился DynamicsCompressorNode, настроенный как быстрый лимитер:

const limiter = audioContext.createDynamicsCompressor();
limiter.threshold.value = -6;
limiter.knee.value = 2;
limiter.ratio.value = 20;
limiter.attack.value = 0.002;
limiter.release.value = 0.12;

roomGain
    .connect(limiter)
    .connect(audioContext.destination);

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

В APK сделали похожую защиту без Web Audio API:

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

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

  • при нескольких активных голосах сумма нормализуется через квадратный корень из их количества;

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

Условно это выглядит так:

сумма голосов
      ↓
деление на √число_активных
      ↓
оценка пика
      ↓
плавный limiter gain
      ↓
диапазон не выше ±30000 в PCM16

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

Вторая причина: пропавшие 20 миллисекунд

Ограничитель убирает перегруз, но не лечит щелчок на разрыве сигнала.

Голос передаётся кадрами по 20 мс. У каждого кадра есть номер последовательности. В идеальном мире они приходят так:

105 → 106 → 107 → 108

В реальной сети иногда получается иначе:

105 → 106 → 108

Пакет 107 потерялся или опоздал. В старой реализации Android-очередь в такой момент заканчивалась, микшер получал пустоту, а затем начинал следующий пакет с его первого сэмпла. Если предыдущий фрагмент закончился, например, на +18000, а новый начался с -12000, между ними возникал почти вертикальный скачок.

Для уха такой скачок и есть щелчок.

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

Сам пакет мог быть совершенно исправным. Трещала граница между пакетами.

Что изменили в буфере

Для веба и Tauri мы немного увеличили целевой запас джиттер-буфера:

было: 60 мс
стало: 85 мс

Максимально допустимый накопленный запас вырос с 240 до 320 мс. Это не попытка победить любую сеть бесконечной задержкой. Буфер по-прежнему ограничен, просто получил ещё 25 мс на обычные колебания доставки.

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

Сокращённо логика выглядит так:

if (sequenceWasInterrupted) {
    const fadeSamples = Math.round(sampleRate * 0.004);

    for (let i = 0; i < fadeSamples; i += 1) {
        const mix = (i + 1) / fadeSamples;
        frame[i] = previousSample * (1 - mix) + frame[i] * mix;
    }
}

Мы не восстанавливаем потерянное слово из воздуха. Задача этого кроссфейда скромнее: не превращать сетевой пропуск в дополнительный резкий щелчок.

В Android-движке для каждого участника уже было предварительное накопление трёх кадров, то есть 60 мс. Но при полном опустошении очереди возвращалась пустота. Теперь в этот момент движок один раз создаёт маскирующий кадр: за первые 80 сэмплов — около 5 мс при 16 кГц — последнее значение плавно уходит к нулю. Когда реальные данные возвращаются, начало первого кадра смешивается с предыдущим состоянием на протяжении 64 сэмплов, примерно 4 мс.

раньше:

последний кадр ─────┐        ┌──── новый кадр
                    └────────┘
                     резкие границы

теперь:

последний кадр ─────╲______╱──── новый кадр
                    5 мс   4 мс

Это простой вариант concealment. Для длинного обрыва он не спасёт разговор, но одиночная потеря перестаёт звучать как электрический разряд.

Автокалибровка тоже вмешивалась в разговор

Параллельно нашёлся ещё один источник нестабильности. После входа в комнату APK делал быстрый трёхсекундный замер, а затем запускал уточняющую калибровку ещё на 57 секунд.

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

Мы разделили автоматическую настройку и обычный разговор:

  • по умолчанию автокалибровка выключена;

  • пользователь включает её явно;

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

  • фонового минутного уточнения больше нет;

  • полученное значение можно дальше поправить обычным ползунком.

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

Почему через минуту чернел стрим

С демонстрацией экрана ситуация сначала выглядела как обычный сетевой обрыв: трансляция запускалась, некоторое время работала, а затем зрители видели чёрный прямоугольник.

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

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

В тестовой ветке использовался simulcast: Desktop одновременно готовил несколько слоёв качества, чтобы LiveKit мог выбирать разрешение для разных зрителей. Для нашей ранней реализации это добавило ещё состояний и переходов, а реальной необходимости в нескольких слоях пока не было.

Мы сделали две вещи:

  • отключили simulcast и оставили один стабильный видеослой;

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

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

LiveKit подключён?
        ↓ да
растёт число отправленных кадров?
        ↓ нет около 15 секунд
остановить зависшую публикацию
        ↓
опубликовать видеотрек заново

Тот же принцип сработал и в расследовании звука: живое соединение ещё не означает, что внутри него продолжают идти правильные данные.

Что проверили после исправления

Исправления вошли в тестовый релиз БОЛТУН 2 0.0.26. После изменений прошли:

  • 26 тестов общего веб-ядра;

  • тест ресемплинга AudioWorklet: одна секунда 48 кГц превращается ровно в 50 кадров по 20 мс без дрейфа;

  • тесты нумерации, дублей и перезапуска голосовых кадров;

  • сборка TypeScript и Vite;

  • релизная сборка Windows/Tauri;

  • релизная сборка Android с Compose, Java и R8;

  • серверные тесты координатора на Go;

  • проверка экранного контура через реальный сервер LiveKit;

  • проверка опубликованных Windows-архива, APK, веб-бандла и манифестов обновления.

Версия 0.0.26 уже выложена в тестовый контур для Windows, веба и Android (versionCode 493). Опубликованный веб-бандл сверили с локальной сборкой, а APK подписан тем же сертификатом, что и предыдущая версия. Значит, следующий живой тест будет проверять именно новый код, а не старый файл из кэша.

Автоматические проверки подтверждают, что проект собирается и новые механизмы не сломали протокол. Но они не умеют услышать щелчок и увидеть чёрный экран. Поэтому окончательная проверка — повторный разговор через Windows, веб и APK продолжительностью 10–15 минут и демонстрация экрана не меньше 10 минут.

До и после

Участок

Было

Стало

Выход веба/Tauri

голоса сразу шли на устройство

общий лимитер перед устройством

Совместное усиление

до ×4 без финальной защиты

уровни сохраняются, пики ограничиваются

Целевой веб-буфер

60 мс

85 мс

Максимальный веб-буфер

240 мс

320 мс

Пропуск sequence number

только обнаружение/отбрасывание

обнаружение плюс кроссфейд 4 мс

Опустошение очереди APK

резкий переход в пустоту

один маскирующий кадр с затуханием 5 мс

Возврат звука в APK

новый кадр начинался как есть

плавный вход около 4 мс

Автокалибровка APK

3 секунды плюс 57 секунд уточнения

выключена по умолчанию, один замер 3 секунды

Видеопубликация

несколько simulcast-слоёв

один стабильный слой

Зависший видеотрек

соединение выглядело живым

watchdog проверяет движение кадров

Восстановление стрима

требовался новый запуск

автоматическая перепубликация примерно через 15 секунд

При чём здесь всё-таки Tauri

Сборка на новом фреймворке действительно стала моментом, после которого мы увидели все три проблемы в одном тесте. Но технически ни одна из них не находилась в кнопках Tauri или в Rust-оболочке.

Треск рождался в общем веб-аудиографе и при восстановлении Android после пропуска пакета. Чёрный экран относился к публикации видеотрека через LiveKit. А плавающая автокалибровка вообще была ошибкой APK, которую просто обнаружил совместный тест трёх платформ.

Миграция изменила сразу несколько условий:

  • нативный Windows-микшер сменился общим веб-аудиографом;

  • вывод оказался внутри WebView2;

  • веб, APK и Desktop начали чаще разговаривать в одной комнате;

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

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

  • публикация экрана получила несколько simulcast-слоёв и больше состояний восстановления.

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

Это, пожалуй, главный урок эксперимента: при миграции real-time приложения нельзя переносить только функции. Нужно переносить ещё и его незаметные ограничения — допустимый пик, размер очереди, поведение при underrun, правила восстановления после пропуска, контроль движения видеокадров и независимость медиатракта от интерфейса.

Что мы забрали из этого расследования

  1. Запись полезнее воспоминаний. Фраза «кажется, трещит» превратилась в измеряемые перегрузы и конкретные места разрывов.

  2. Два регулятора громкости перемножаются. Если каждый разрешает 200%, на выходе легко получить 400% ещё до сложения собеседников.

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

  4. Потеря пакета и щелчок — не одно и то же. Пакет уже потерян сетью, но резкую границу создаёт проигрыватель. Её можно сгладить.

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

  6. Автокалибровка не должна тайно продолжаться во время разговора. Любая автоматическая регулировка обязана иметь понятный момент начала и конца.

  7. Живое соединение не гарантирует живой поток. Для стрима нужно наблюдать не только состояние сессии, но и движение кадров.

  8. Зелёная сборка не слышит звук и не видит чёрный экран. После модульных тестов всё равно нужны люди, разные устройства и запись результата.

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

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

Проект: boltun.org

Комментарии (2)


  1. anonym0use
    28.08.2026 21:54

    Сорян, не удержался)
    Сорян, не удержался)


    1. PAPADAN Автор
      28.08.2026 21:54

      уже почти не работает)))))))))