P
PAPADAN
Гость
В предыдущей статье мы сравнили старый 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-фреймы» тоже не сходилась. Оставалось сочетание двух эффектов:
- Сигнал иногда перегружался при смешивании.
- При пропуске или опоздании пакета возникала резкая граница между двумя кусками волны.
Оба эффекта на слух легко объединяются в одно слово — треск.
Первая причина: громкость можно было умножить на четыре
В новом клиенте есть две независимые настройки:
- общая громкость голосового канала — до 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, правила восстановления после пропуска, контроль движения видеокадров и независимость медиатракта от интерфейса.
Что мы забрали из этого расследования
- Запись полезнее воспоминаний. Фраза «кажется, трещит» превратилась в измеряемые перегрузы и конкретные места разрывов.
- Два регулятора громкости перемножаются. Если каждый разрешает 200%, на выходе легко получить 400% ещё до сложения собеседников.
- Лимитер нужен после всего микса. Ограничить каждый источник отдельно недостаточно, если затем они складываются.
- Потеря пакета и щелчок — не одно и то же. Пакет уже потерян сетью, но резкую границу создаёт проигрыватель. Её можно сгладить.
- Двадцать пять миллисекунд запаса иногда дешевле одного щелчка. Для голоса важна задержка, но слишком хрупкий буфер не становится быстрее — он становится слышимым.
- Автокалибровка не должна тайно продолжаться во время разговора. Любая автоматическая регулировка обязана иметь понятный момент начала и конца.
- Живое соединение не гарантирует живой поток. Для стрима нужно наблюдать не только состояние сессии, но и движение кадров.
- Зелёная сборка не слышит звук и не видит чёрный экран. После модульных тестов всё равно нужны люди, разные устройства и запись результата.
Мы начинали этот этап с вопроса, можно ли заменить старый Win32-интерфейс на Tauri. После бенчмарка вопрос стал сложнее: можно ли сохранить удобство нового интерфейса, не потеряв устойчивость старого медиатракта.
Ответ пока выглядит так: можно, но только если перестать считать звук и видео приложением к интерфейсу. Для программы связи медиатракт и есть продукт. Всё остальное может быть красивым, быстрым и мультиплатформенным — но только после того, как слово перестало заканчиваться треском, а живая трансляция — чёрным прямоугольником.
Проект: boltun.org
Источник: habr.com


