Содержание статьи +
- Кратко
- Почему это важно
- Проблема: ровный отправитель и неровная сеть
- Что такое буфер джиттера, на одной аналогии
- Наивное решение и почему оно не работает
- Знакомьтесь, NetEQ
- Что происходит на каждом такте: пять (с хвостиком) операций
- Как NetEQ решает, сколько буферизовать
- Разбор примера: превращаем джиттер в размер буфера
- Метрики, за которыми стоит следить каждому инженеру
- Частая ловушка: винить NetEQ за проблему выше по цепочке
- Как это связано с потерей пакетов и избыточностью
- Где здесь Фора Софт
- Главное
- Что почитать дальше
Кратко
Пакеты выходят от отправителя ровным ритмом – по одному каждые 20 миллисекунд, – но интернет доставляет их неравномерно: то всплесками, то с опозданием, а иногда не доставляет вовсе; этот разброс во времени прихода называется джиттером. Буфер джиттера – это «зал ожидания», который удерживает приходящие звуковые пакеты ровно настолько, чтобы превратить неровный поток обратно в ровный, пригодный для воспроизведения. В WebRTC буфер джиттера – это компонент NetEQ, и он адаптивный: он постоянно оценивает, насколько нестабильна сеть, и увеличивает или уменьшает время удержания, чтобы найти баланс между лишними миллисекундами задержки и риском «опустеть». В статье простым языком объясняется, что NetEQ делает на каждом 10-миллисекундном такте, какие пять операций он выполняет, когда пакет опаздывает или потерян, как он измеряет задержку и какие метрики в продакшене покажут, здоров ли ваш звук.
Почему это важно
Если вы делаете видеозвонки, телемедицину, контакт-центр или онлайн-классы, самая частая жалоба ваших пользователей – «звук рвался» или «была задержка». Почти каждая из них восходит к поведению буфера джиттера на плохой сети – а в WebRTC этим буфером является NetEQ: компонент, который вы не пишете сами, но обязаны понимать. Статья написана для продакт-менеджера, основателя или операционного руководителя, которому нужно понять компромисс, которым управляет NetEQ – плавность против задержки, – чтобы прочитать диагностическую метрику и задать инженеру точный вопрос. Senior-инженер тоже найдёт здесь каждый факт, прослеженный до документации исходников WebRTC, спецификации RTP или опубликованного разбора от сопровождающих.
Проблема: ровный отправитель и неровная сеть
Начнём с того, что делает отправитель. Микрофон непрерывно захватывает звук, кодер режет его на небольшие куски – обычно по 20 миллисекунд каждый, называемые кадром (frame), – и система оборачивает каждый кадр в пакет и отправляет. Двадцать миллисекунд на кадр означает, что отправитель выдаёт по одному пакету каждые 20 миллисекунд, как метроном: тик, тик, тик, пятьдесят раз в секунду, идеально равномерно.
Интернет эту равномерность не сохраняет. Пакеты идут через маршрутизаторы, коммутаторы, Wi-Fi и сотовые участки, и каждый участок добавляет задержку, меняющуюся от момента к моменту. Поэтому пакеты, вышедшие от отправителя с шагом 20 миллисекунд, приходят к получателю неравномерно – то через 18 миллисекунд, то через 35, то два сразу, то с провалом в 60 миллисекунд. Этот разброс во времени прихода и есть джиттер – буквально «дёрганость» расписания прихода пакетов.
Инженер Meta, сопровождающий код устойчивости звука WebRTC, описывает три формы джиттера в реальном мире. Самая частая, как ни странно, – всплесковый приход (bursty): получатель какое-то время не слышит ничего, потом несколько пакетов приходят разом. Дальше мелкий джиттер: один-два пакета слегка опаздывают, а следующие быстро навёрстывают. И постоянное изменение задержки: с какого-то момента каждый пакет просто идёт дольше, без джиттера потом – например, когда телефон переключается с Wi-Fi на сотовую сеть (webrtcHacks, «How WebRTC's NetEQ Jitter Buffer Provides Smooth Audio», июнь 2025).
Поверх джиттера пакеты иногда вовсе не доходят – это потеря пакетов – или приходят не по порядку. Динамик при этом беспощаден: ему нужны свежие, непрерывные 10 миллисекунд звука, вовремя, каждые 10 миллисекунд, всегда. Если звука нет, слушатель слышит провал. Компонент, который стоит между неровной сетью и беспощадным динамиком и делает второго довольным вопреки первой, – это и есть буфер джиттера.
Что такое буфер джиттера, на одной аналогии
Буфер джиттера – как зал ожидания у выхода на посадку. Пассажиры (пакеты) приходят к выходу по неровному расписанию – кто раньше, кто позже, кто торопится толпой. Зал ожидания удерживает их, чтобы посадка (воспроизведение) шла по ровному, предсказуемому графику, а не дёргалась каждый раз, когда приход скучивается. Чем дольше вы держите людей в зале, тем больше у вас запаса на опоздавший рейс – но тем дольше общий путь каждого.
Вот и весь компромисс в одной картинке. Больший буфер удерживает больше пакетов, поэтому переживёт более долгий сетевой сбой, не опустев, – но добавляет задержку в разговор. Меньший буфер держит разговор живым – но первый же сетевой всплеск, превысивший время удержания, даёт слышимый провал. Сделаете слишком маленьким – звук рвётся; слишком большим – люди начинают перебивать друг друга, потому что круговая задержка ощущается вялой.
Для живого разговора потолок задержки строгий. Естественный диалог разваливается, когда задержка в одну сторону переваливает примерно за 200 миллисекунд, и становится по-настоящему трудным после ~500 миллисекунд – за этим порогом люди перебивают друг друга, потому что пауза перед ответом кажется заминкой (webrtcHacks, июнь 2025). Сравните это с просмотром видео в стриминге, где плеер спокойно буферизует несколько секунд, потому что вы не замечаете задержку, с которой вам не с чем сравнивать. Плеер фильма может держать минуты; приложение лайв-стриминга – секунды; реал-тайм-звонок – максимум несколько сотен миллисекунд. Поэтому буфер джиттера в WebRTC нельзя просто сделать большим и безопасным – он живёт в жёстком бюджете задержки.
Наивное решение и почему оно не работает
Простейший буфер джиттера – фиксированный: всегда удерживай, скажем, 100 миллисекунд звука перед началом воспроизведения и никогда не меняй. Это легко сделать, и на стабильной сети работает. На реальной сети оно проваливается по двум противоположным причинам.
Если сеть спокойнее, чем 100 миллисекунд джиттера, фиксированный буфер добавляет 100 миллисекунд ненужной задержки – чистая потеря, ощущаемая как вялость. Если сеть становится хуже, чем 100 миллисекунд джиттера, буфер опустевает посреди фразы, и слушатель слышит провал. Поэтому фиксированный буфер почти всегда неправилен: слишком большой для хороших сетей, слишком маленький для плохих. Единственный правильный буфер – тот, что меняет свой размер под сеть, на которой он сейчас находится. Это и значит «адаптивный», и в этом вся сложность NetEQ.
Знакомьтесь, NetEQ
NetEQ – название это сокращение от «Network Equalizer» – это адаптивный буфер джиттера и компенсатор потерь пакетов внутри libWebRTC, опенсорсной реализации WebRTC, которая работает в Chrome, Edge и большинстве нативных голосовых приложений. Он зародился в Global IP Solutions (GIPS), компании, которую Google купил в 2011 году для постройки WebRTC, и с тех пор тихо совершенствуется. Его цель, по собственной документации проекта WebRTC, – «обеспечить плавное воспроизведение приходящих звуковых пакетов с минимумом артефактов и при этом держать задержку как можно ниже» (документация WebRTC NetEq, chromium.googlesource.com). Эти две цели – плавность и низкая задержка – тянут в разные стороны, и управление этим напряжением – вся работа NetEQ.
У NetEQ всего две публичные точки входа, и понять их – половина дела.
Первая – InsertPacket: сеть передаёт NetEQ только что пришедший RTP-пакет, и NetEQ откладывает его. Вторая – GetAudio: звуковое устройство просит у NetEQ следующий кусочек звука для воспроизведения, и NetEQ должен вернуть ровно 10 миллисекунд звука – не больше и не меньше, – и его вызывают для этого 100 раз в секунду, по строгому графику (документация WebRTC NetEq). InsertPacket срабатывает, когда приходит пакет, по неровному расписанию сети. GetAudio работает как часы, 100 раз в секунду, по ровному графику динамика. NetEQ – это коробка передач, соединяющая неровный входной вал с ровным выходным.
Что происходит на каждом такте: пять (с хвостиком) операций
Вот суть. Каждые 10 миллисекунд динамик вызывает GetAudio, и NetEQ обязан выдать 10 миллисекунд звука. Для этого он советуется с двумя внутренними «мозгами» – менеджером задержки (delay manager), оценивающим, сколько буферизации сейчас требует сеть, и логикой решений (decision logic), выбирающей, что именно сделать в этом такте, – и выполняет ровно одну из небольшого набора операций. Назвать эти операции – самое полезное в статье, потому что каждый симптом, о котором сообщат пользователи, соответствует одной из них, происходящей слишком часто.
Normal (норма). Ожидаемый пакет лежит в буфере, вовремя. NetEQ декодирует его и воспроизводит на естественной скорости. Ничего хитрого. На здоровой сети почти каждый такт – Normal, и это именно то, что нужно.
Acceleration (ускорение). В буфере звука больше, чем оправдывает текущая сеть – пакеты приходили рано или в спокойный момент, – поэтому разговор слегка отстал от реального времени. NetEQ ускоряет звук на микроскопическую величину, чтобы слить излишек и нагнать, используя растяжение времени, которое укорачивает звук без подъёма высоты тона, так что динамик не превращается в «бурундука» (документация WebRTC NetEq). Одно ускорение вы бы не услышали; тысячи слышны как лёгкое неестественное ускорение.
Preemptive expand (замедление). Противоположный случай: буфер на исходе, и есть реальный риск, что на следующем такте он опустеет. NetEQ замедляет звук на микроскопическую величину – слегка растягивая его без понижения высоты тона, – чтобы выиграть время на приход следующего пакета (документация WebRTC NetEq). Массово это слышно как лёгкое «затягивание».
Expand (маскировка потерь, PLC). Нужного пакета нет – он потерян или просто опоздал, – и декодировать нечего. Вместо тишины NetEQ синтезирует правдоподобное продолжение звука: экстраполирует то, что уже есть, повторяя и затухая недавнюю форму волны, или просит встроенную в кодек процедуру маскировки сгенерировать её (документация WebRTC NetEq). Немного этого неслышно. Много – это тот самый роботизированный, «булькающий», смазанный звук, который все связывают с плохим звонком.
Merge (склейка). Когда настоящий пакет наконец приходит сразу после того, как NetEQ синтезировал поддельный звук операцией Expand, их нельзя просто состыковать – на шве будет щелчок. Merge плавно вшивает результат маскировки в свежедекодированный настоящий звук, чтобы стык был неслышен (документация WebRTC NetEq).
Есть и шестая, особая операция – для тишины. Когда отправитель использует прерывистую передачу (DTX) – намеренно не шлёт пакеты в тишине, чтобы экономить полосу, – NetEQ не считает паузу потерей. Вместо этого он генерирует комфортный шум (comfort noise): тихий, подобранный шипящий фон, чтобы линия не звучала «мёртвой» (документация WebRTC NetEq). Этот механизм мы подробно разбираем в статье про детектор речевой активности и DTX; здесь достаточно знать, что у NetEQ есть отдельная ветка для этого и он не путает намеренную тишину с потерянным пакетом.
Как NetEQ решает, сколько буферизовать
Две операции растяжения – Acceleration и Preemptive expand – имеют смысл, только если NetEQ знает, сколько звука он должен держать. Это число – целевая задержка (target delay; в коде «target level»), и хорошо его вычислять – разница между буфером, мягко плывущим вместе с сетью, и дёргающимся.
Старый, интуитивный метод – измерять промежуток между соседними пакетами (межпакетная задержка, inter-arrival) и подгонять буфер под худший виденный промежуток. Это работает при простом джиттере, но ломается при накапливающейся задержке, когда каждый следующий пакет чуть позже предыдущего. Разбор Meta проходит ровно этот провал: при межпакетном измерении буфер постоянно недобирает 40 миллисекунд на каждом пакете во время медленного нарастания, давая повторяющиеся опустошения, хотя алгоритм «думает», что цель верна (webrtcHacks, июнь 2025).
В 2022 году WebRTC заменил это на относительную задержку (relative delay). Вместо сравнения каждого пакета с непосредственным предшественником NetEQ выбирает единственный самый быстрый пакет в недавнем временном окне – тот, что прошёл путь короче всех, – и измеряет задержку каждого другого пакета относительно этого якоря (webrtcHacks, июнь 2025). Привязка к самому быстрому пакету означает, что медленное накапливающееся нарастание измеряется от по-настоящему быстрой базы, поэтому целевая задержка растёт достаточно, чтобы покрыть всё нарастание, а не хронически отставать от него. Временное окно, задающее «недавнее», по умолчанию 2 секунды в libWebRTC – намеренно выбранная эвристика, отделяющая временный джиттер (от которого стоит буферизовать) от постоянного разового сдвига задержки (от которого не стоит, потому что он не повторится) (webrtcHacks, июнь 2025).
Менеджер задержки не просто берёт наибольшую относительную задержку, что когда-либо видел, – иначе один аномальный пакет раздул бы буфер навсегда. Вместо этого он ведёт гистограмму: текущий подсчёт того, как часто встречается каждое значение задержки, где каждое новое измерение слегка сдвигает счёт, а старые медленно угасают. Угасание управляется фактором забывания (forget factor), по умолчанию зашитым в 0.983, выбранным так, чтобы гистограмма помнила редкие события, не цепляясь за древние (webrtcHacks, июнь 2025). Затем NetEQ читает с гистограммы высокий перцентиль – например, ставит цель так, чтобы 95 процентов недавних пакетов успевали вовремя. Выше перцентиль – безопаснее и больше буфер; ниже – живее и рискованнее. Этот перцентиль – одна из немногих настоящих «крутилок», доступных нативному приложению.
Есть и вторая гистограмма – оптимизатор переупорядочивания (reorder optimizer), – обрабатывающая пришедшие не по порядку и переотправленные пакеты. Поскольку переотправленный звуковой пакет в WebRTC выглядит идентично обычному – у них один идентификатор потока, и получатель не различает их, – поздние переотправки естественно выглядят как джиттер и мягко увеличивают буфер (webrtcHacks, июнь 2025). Оптимизатор переупорядочивания взвешивает ценность восстановления этих пакетов против стоимости задержки на их ожидание, используя явную функцию стоимости вместо фиксированного перцентиля. Менеджер задержки берёт ту из двух гистограмм, что требует больше буферизации, и использует её как итоговую цель.
Разбор примера: превращаем джиттер в размер буфера
Числа делают это конкретным. Пусть ваш звук использует кадры по 20 миллисекунд, то есть отправитель выдаёт 50 пакетов в секунду. Вы наблюдаете приход в течение двух секунд и обнаруживаете, что относительно самого быстрого пакета в этом окне задержки распределяются так: большинство пакетов приходят в пределах 20 миллисекунд от якоря, но заметная доля отстаёт сильнее.
Пусть гистограмма относительных задержек выходит такой: 60 процентов пакетов в пределах 20 мс, 20 процентов – в пределах 40 мс, 10 процентов – 60 мс, 6 процентов – 80 мс и 4 процента – 100 мс. Чтобы покрыть 95 процентов пакетов, NetEQ читает гистограмму вверх, пока накопленная доля впервые не перейдёт 0.95:
в пределах 20 мс: 0.60 (накоплено 0.60)
в пределах 40 мс: 0.20 (накоплено 0.80)
в пределах 60 мс: 0.10 (накоплено 0.90)
в пределах 80 мс: 0.06 (накоплено 0.96) ← первая корзина за 0.95
в пределах 100 мс: 0.04 (накоплено 1.00)Накопленная доля впервые превышает 0.95 на корзине 80 миллисекунд, поэтому NetEQ ставит целевую задержку в 80 миллисекунд. Толкование: удержание 80 миллисекунд звука означает, что 96 процентов пакетов на этой сети будут в буфере к своей очереди на воспроизведение; оставшиеся 4 процента – худшие отстающие – запустят Expand. Подняв перцентиль до 0.99, цель прыгнет до 100 миллисекунд: безопаснее против отстающих, но на 20 миллисекунд больше задержки на каждом слове. Этот единственный арифметический шаг – выбрать перцентиль, прочитать размер буфера – и есть компромисс, который NetEQ совершает тысячи раз за звонок, и тот самый, который вы настраиваете, трогая «крутилку».
Метрики, за которыми стоит следить каждому инженеру
Вы не видите работу NetEQ, но можете прочитать его диагностику. Браузер отдаёт её через стандартный API getStats(), определённый в спецификации W3C WebRTC Statistics, и вот те поля, что стоит вынести на дашборд. Каждое живёт в отчёте по входящему звуковому потоку (W3C webrtc-stats, Candidate Recommendation).
Самое важное – средняя задержка буфера джиттера. Спецификация даёт два сырых счётчика – jitterBufferDelay, суммарные секунды буферизации по всем выданным сэмплам, и jitterBufferEmittedCount, число выданных сэмплов, – и вы делите первое на второе, чтобы получить среднюю задержку, которую каждый сэмпл провёл в буфере (W3C webrtc-stats). Это заглавное число: сколько задержки сейчас добавляет NetEQ. Есть и jitterBufferTargetDelay – цель, к которой стремится менеджер задержки исходя чисто из сетевых условий, отдельно от любой добавочной задержки для синхронизации звука и видео (W3C webrtc-stats, RTCInboundRtpStreamStats).
Далее – счётчики маскировки. concealedSamples считает сэмплы, подделанные из-за отсутствия пакета, а concealmentEvents – сколько отдельных раз маскировке пришлось начаться, удобный показатель того, как часто буфер опустевал (W3C webrtc-stats). Горстка событий маскировки на длинном звонке нормальна; доля, дорастающая до процентов от всех сэмплов, слышна как рваный звук.
Наконец, счётчики растяжения времени. insertedSamplesForDeceleration считает сэмплы, добавленные при замедлении воспроизведения (Preemptive expand), а removedSamplesForAcceleration – сэмплы, отброшенные при ускорении (Acceleration) (W3C webrtc-stats). Когда они растут вместе, это обычно сигнал нестабильного буфера, который постоянно «охотится» – то замедляется, то ускоряется, – а это указывает на шумную оценку сети, а не на одну чистую проблему.
| Метрика (getStats) | Что показывает | Здоровое направление |
|---|---|---|
| jitterBufferDelay / jitterBufferEmittedCount | Средние мс ожидания сэмпла в буфере | Низко и стабильно |
| jitterBufferTargetDelay / jitterBufferEmittedCount | Размер буфера, к которому стремится NetEQ | Следует за сетью, без скачков |
| concealmentEvents | Как часто буфер опустевал и подделывал звук | Почти ровно во времени |
| concealedSamples | Объём поддельного звука | Крошечная доля от всех сэмплов |
| insertedSamplesForDeceleration | Звук, добавленный замедлением | Низко; рост = давление опустошения |
| removedSamplesForAcceleration | Звук, отброшенный ускорением | Низко; рост вместе с предыдущим = «охота» |
Чтобы смотреть NetEQ вживую, откройте chrome://webrtc-internals во время любого WebRTC-звонка в Chrome и найдите секцию входящего звука – каждый счётчик выше там строится в реальном времени, и это самый быстрый способ подтвердить, действительно ли жалоба на «плохой звук» – проблема буфера джиттера или что-то выше по цепочке. Для офлайн-анализа проект WebRTC поставляет инструмент neteq_rtpplay, который прогоняет захваченный RTP-дамп или PCAP-файл через NetEQ и печатает агрегированную статистику, так что плохой звонок клиента можно воспроизвести у себя на столе (документация WebRTC NetEq).
Частая ловушка: винить NetEQ за проблему выше по цепочке
Самая дорогая ошибка команд при диагностике буфера джиттера – считать высокую долю маскировки доказательством того, что «буфер джиттера сломан». NetEQ стоит ниже всего по течению: кодера, сети, контроллера перегрузки и звукового устройства. Поток операций Expand почти всегда означает, что пакеты действительно не приходят – из-за реальной потери в сети, коллапса контроля перегрузки или отправителя, срезавшего битрейт, – а не потому, что NetEQ выбрал плохо. Правка перцентиля NetEQ, когда настоящая проблема – 8 процентов потери пакетов, лишь добавляет задержку, не убирая провалов.
Вторая, тоньше, ловушка – взаимодействие с синхронизацией звука и видео. NetEQ можно намеренно заставить увеличить задержку, чтобы держать звук в линии с более медленным видеоконвейером (документация WebRTC NetEq). Если ваш видеопуть тормозит, NetEQ придержит звук под него, и метрика задержки буфера будет высокой не по вине сети. Лечится это в видеопути, а не в звуковом буфере. Поэтому метрику jitterBufferTargetDelay стоит смотреть отдельно – она показывает цель, обусловленную сетью, до любой добавки на синхронизацию, так что можно отличить настоящую сетевую проблему от подстройки синхронизации.
Третья ловушка – чрезмерная настройка. Дефолты NetEQ – фактор забывания 0.983, окно 2 секунды, высокий перцентиль – выбраны на огромном, разнообразном трафике. Они не оптимальны для каждого продукта, но это сильная база, и команда, начинающая крутить «крутилки» до того, как прочитала метрики, обычно делает хуже. Сначала измеряйте; правильный ход почти всегда – устранить проблему сети или устройства выше по цепочке, на которую указывают метрики.
Как это связано с потерей пакетов и избыточностью
Операция Expand в NetEQ – последняя линия обороны, и намеренно худшая: поддельный звук никогда не так хорош, как настоящий. Всё, что остальная часть звукового стека WebRTC делает, чтобы избежать нужды в Expand, стоит выше NetEQ и его стоит понимать рядом с ним. Маскировка потерь пакетов – семейство алгоритмов, делающих Expand менее «сломанным»; современные кодеки и нейросетевые модели продвинули это удивительно далеко, что мы разбираем в статье про маскировку потерь пакетов. Упреждающая коррекция ошибок (FEC) шлёт немного избыточных данных, чтобы потерянный пакет можно было восстановить без переотправки, и NetEQ явно выделяет и приоритизирует эту избыточность, когда она приходит (документация WebRTC NetEq); компромиссы мы разбираем в статье про FEC, in-band FEC и избыточность RED. Эти три темы – буферизация джиттера, маскировка и избыточность – полный набор «что делать с несовершенной доставкой», и настраиваются они вместе.
Где здесь Фора Софт
Фора Софт встраивает реал-тайм-звук в видеоконференции, телемедицину, онлайн-образование и лайв-шопинг с 2005 года, и поведение NetEQ – рутинная часть того, как мы диагностируем и настраиваем эти системы. Когда клиент сообщает о рваном звуке, первый шаг почти никогда не трогает буфер – это прочитать счётчики маскировки и задержки в getStats и решить, в чём проблема: в сети, устройстве, контроллере перегрузки или действительно в оценке буфера. Телемедицинскому звонку на больничном Wi-Fi и вебинару на 500 человек на разношёрстных пользовательских каналах нужно разное поведение буфера, и правильная настройка идёт из измеренного профиля джиттера, а не из догадки. Мы инструментируем эти метрики с самого начала, чтобы «звук сломан» стало числом, которое можно прочитать, а не загадкой, которую надо воспроизводить.
Главное
- Джиттер – это неровный приход ровно отправленных пакетов; буфер джиттера сглаживает его обратно.
- NetEQ – адаптивный звуковой буфер джиттера WebRTC: он подстраивает свой размер под живую сеть.
- Каждые 10 мс он делает одно из: Normal, Acceleration, Preemptive expand, Expand, Merge или комфортный шум.
- Целевая задержка идёт от относительной задержки (2022), прочитанной с забывающей гистограммы по высокому перцентилю.
- Следите за jitterBufferDelay, concealmentEvents и счётчиками ускорения/замедления в getStats.
- Высокая доля маскировки обычно означает реальную потерю пакетов выше – чините сеть, а не буфер.