javascript

Один пакет — один мини-плеер: почему мы перенесли голос БОЛТУНа в AudioWorklet

  • среда, 2 сентября 2026 г. в 00:00:11
https://habr.com/ru/articles/1077046/

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

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

Причина оказалась не в сети и не в кодеке. Мы слишком буквально превратили сетевые пакеты в единицы воспроизведения.

Один пакет — один плеер

БОЛТУН передаёт голос кадрами по 20 мс. При частоте 16 кГц в одном кадре находится 320 сэмплов.

Старая схема воспроизведения выглядела примерно так:

WebSocket
    ↓
декодирование пакета
    ↓
новый AudioBuffer
    ↓
новый AudioBufferSourceNode
    ↓
планирование времени запуска
    ↓
общий микшер

На каждый пакет создавался отдельный AudioBufferSourceNode:

const buffer = context.createBuffer(1, samples.length, sampleRate);
buffer.copyToChannel(samples, 0);

const source = context.createBufferSource();
source.buffer = buffer;
source.connect(peerGain);
source.start(startTime);

Для одного говорящего это 50 маленьких источников звука в секунду. Для четырёх собеседников — уже 200. Каждый из них нужно создать, подключить к графу Web Audio и вовремя запустить.

Чтобы пакеты не сталкивались друг с другом, мы хранили для каждого участника расчётное время следующего запуска. Перед началом речи накапливали запас примерно в 180 мс, а при опозданиях увеличивали его до 300 мс.

На бумаге всё выглядело разумно. В обычном браузере схема действительно могла долго работать нормально. Но внутри WebView2 основной поток занят не только голосом: он обновляет интерфейс, принимает сообщения, рисует визуализацию и иногда останавливается на сборку мусора.

Если JavaScript запланировал очередной источник слишком поздно, браузер уже не мог вернуть пропущенные несколько миллисекунд. Между двумя 20-мс кусками появлялась маленькая пустота. Иногда к ней добавлялся скачок на границе сэмплов и повторный старт преобразования 16 кГц в частоту звукового устройства.

Для глаза такая задержка незаметна. Для уха это щелчок.

Мы оставили пакеты, но убрали мини-плееры

Менять сетевой протокол не потребовалось. По WebSocket по-прежнему приходят бинарные голосовые кадры с номером последовательности и временем захвата. Формат PCM16 и размер кадра тоже остались прежними.

Мы изменили следующий участок пути:

Было:
пакет → декодер → отдельный источник → микшер

Стало:
пакет → декодер → очередь одного AudioWorklet → микшер

Теперь для каждого удалённого участника создаётся один AudioWorkletNode. Основной поток только передаёт ему готовые сэмплы:

const queuedSamples = samples.slice();

playbackNode.port.postMessage({
  type: "push",
  samples: queuedSamples,
  sampleRate,
}, [queuedSamples.buffer]);

Внутри worklet находится кольцевой буфер. Он сначала собирает 200 мс звука, а затем начинает непрерывно отдавать его аудиоустройству блоками по 128 сэмплов.

В нашем случае входной голос идёт с частотой 16 кГц, а звуковой контекст обычно работает на 48 кГц. Worklet хранит общую позицию чтения и делает линейную интерполяцию, поэтому преобразование частоты больше не начинается заново на каждом сетевом пакете.

Главное отличие простое: граница пакета осталась в сети, но исчезла из плеера.

Что происходит при задержке

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

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

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

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

Проверили не только ушами

Для нового плеера мы добавили отдельный тест. Он собирает 15 последовательных кадров синусоиды частотой 730 Гц. Каждый кадр содержит те же 320 сэмплов, что и настоящий голосовой пакет.

Затем тест запускает worklet блоками по 128 сэмплов, как это делает Web Audio, и проверяет три вещи:

  • на границах 20-мс пакетов нет провалов в энергии;

  • максимальный скачок между соседними выходными сэмплами остаётся меньше 0.08;

  • после настоящего опустошения буфера плеер возвращается в режим предварительного накопления.

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

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

Что в итоге изменилось

Раньше основной поток JavaScript одновременно принимал пакет, создавал аудиоузел и назначал точное время его запуска. Теперь он только складывает данные в очередь, а непрерывностью занимается аудиопоток браузера.

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

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

Именно этот слой мы раньше пытались заменить аккуратным расписанием. Теперь у БОЛТУНа вместо расписания появился настоящий поток.

Короче, схема в итоге рабочая. Но чтобы во всём этом разобраться, нам понадобилось две недели. Так что вот вам уже протестированное и готовое решение — возможно, кому-нибудь оно сэкономит немало времени.

А мы продолжаем дальше пилить замену Discord ))))