Содержание статьи +
- Коротко
- Почему это важно
- Сначала – что вообще требуется для «синхронности»
- Две метки времени, делающие синхронизацию возможной
- Разбор на числах: как из меток времени получается смещение синхронизации
- Почему SFU – это место, где ломается синхронизация губ
- Как это делает mediasoup
- Как это делает Pion
- Как это работает в LiveKit
- Две стратегии и три движка в сравнении
- Частые ошибки, превращающие синхронизацию в баг
- Где здесь Фора Софт
- Ключевые выводы
- Что почитать дальше
Коротко
WebRTC обеспечивает синхронизацию голоса говорящего с движением его губ, используя две разновидности временных меток для каждого аудио- и видеопотока: быструю метку медиа-часов в каждом пакете и редкие показания настенных часов, передаваемые в RTCP Sender Report. Приёмник затем выравнивает оба потока по этим общим настенным часам. В звонке один-на-один эта задача решена, и вы даже не задумываетесь о ней. Проблемы возникают, когда в цепочке появляется SFU (selective forwarding unit), поскольку SFU завершает RTCP от отправителя и вынужден генерировать собственные Sender Report. В результате сохраняется ли синхронизация губ и звука в mediasoup, Pion или LiveKit – полностью зависит от того, насколько точно они переписывают временные метки и поддерживают новый механизм под названием Absolute Capture Time. Эта статья простым языком объясняет работу временных меток, анализирует подход каждого из трёх движков, а также показывает, где именно нарушается синхронизация и как это можно обнаружить.
Почему это важно
Если вы разрабатываете продукт видеоконференций, платформу вебинаров, телемедицинское приложение или любую функцию групповых звонков на базе WebRTC, жалоба «звук чуть впереди видео» рано или поздно окажется на вашем столе – и почти всегда это не то, на что в первую очередь указывают. Продакт-менеджеру важно понимать, почему синхронизация губ (lip-sync), работавшая идеально в демо на двоих, начинает сбиваться на встрече из пятидесяти человек: проблема здесь – в архитектуре, а не в случайном баге. Инженеру, выбирающему между mediasoup, Pion и LiveKit, нужно знать, кто из них корректно ведёт учёт временных меток и где у кого есть известные проблемы. Весь смысл lip-sync в том, что его никто не замечает; тем, кто строит систему, – единственным, кому вообще приходится о нём думать, – нужно подходить к этому вопросу тщательно.
Сначала – что вообще требуется для «синхронности»
Прежде чем изучать любой движок, важно чётко понимать, что именно должен делать приёмник. Аудио и видео говорящего захватываются как два отдельных потока. Каждый из них кодируется своим кодеком – Opus для голоса и один из VP8, VP9, AV1 или H.264 для видео – и передаётся как независимые потоки пакетов. В сети они не объединяются. Приёмнику необходимо восстановить исходную временную синхронизацию: звук, соответствующий определённому моменту, когда губы находились в определённой позе, должен выйти из динамиков ровно в тот момент, когда этот кадр появится на экране.
Допуск на ошибку здесь хорошо изучен. Вещательный стандарт ITU-R BT.1359-1 кладёт предел приемлемости на отметке: аудио опережает видео примерно на 90 миллисекунд или отстаёт от него примерно на 185 миллисекунд. В пределах этого окна большинство зрителей не замечают рассинхронизации. За его пределами речь начинает восприниматься как несогласованная – возникает эффект дублированного фильма, из-за которого за говорящим трудно следить, а продукт кажется дешёвым. Поэтому задача приёмника – не добиться идеального нулевого смещения, а на протяжении всего звонка удерживать его в пределах этого перцептивного окна.
Две метки времени, делающие синхронизацию возможной
WebRTC наследует механизмы тайминга от протокола RTP и управляющего протокола RTCP, оба из которых определены в IETF RFC 3550. Здесь задействованы две метки времени, и весь механизм строится на их взаимодействии.
Первая – это RTP timestamp, передаваемый в заголовке каждого медиапакета. Это 32-битный счётчик, который увеличивается с частотой, определяемой кодеком: 48 000 Гц для аудио Opus и 90 000 Гц для видео. Эта метка указывает приёмнику, сколько времени прошло между одним пакетом и следующим внутри одного потока, но не позволяет установить связь между разными потоками. Часы аудио и видео стартуют с независимых случайных смещений и идут с разной частотой, поэтому аудио RTP timestamp 480 000 и видео RTP timestamp 900 000 не связаны напрямую. Думайте о RTP timestamp как о секундомере, запущенном с произвольного значения: он полезен для измерения интервалов, но бесполезен для определения абсолютного времени.
Вторая – NTP timestamp, и появляется он не на каждом пакете, а внутри RTCP Sender Report, отправляемого раз в несколько секунд. NTP-время – это настенное время, фактическое время суток, выраженное 64-битным значением. Sender Report – это и есть критический мост: он связывает два часовых механизма вместе, по сути говоря: «в этот момент настенного времени мой RTP timestamp для этого потока был ровно таким». Как только у приёмника есть такая пара для аудиопотока и соответствующая пара для видеопотока, он может перевести любой RTP timestamp любого из потоков в настенное время – и теперь оба потока живут на одной общей временной оси. Их выравнивание становится простой арифметикой.
Ещё один элемент, скрепляющий всё вместе, – это CNAME, канонический идентификатор, передаваемый в RTCP. Он одинаков для всех потоков, исходящих от одного участника. Приёмник использует CNAME, чтобы определить, что данный аудиопоток и тот видеопоток принадлежат одному и тому же человеку и должны синхронизироваться между собой. Без него в комнате, где много людей одновременно передают аудио и видео, у приёмника не было бы надёжного способа сопоставить голос с лицом.
Разбор на числах: как из меток времени получается смещение синхронизации
Числа делают механизм наглядным, поэтому вот арифметика, которую обрабатывает приёмник.
Допустим, у приёмника есть аудио Sender Report, сообщающий, что аудио RTP timestamp 4 800 000 соответствует NTP-времени T. Аудио передаётся с частотой 48 000 Гц, поэтому более поздний аудиопакет с RTP timestamp 4 848 000 приходит ровно через одну секунду после T:
(4 848 000 − 4 800 000) ÷ 48 000 Гц = 48 000 ÷ 48 000 = 1,000 секунды после TТеперь предположим, что в отчёте отправителя (Sender Report) указано, что временной метке RTP 900 000 соответствует NTP-время T. Видео передаётся с частотой 90 000 Гц, поэтому кадр с временной меткой RTP 990 000 будет находиться на:
(990 000 − 900 000) ÷ 90 000 Гц = 90 000 ÷ 90 000 = 1,000 секунды после TОба пакета – аудио и видео – приходят с задержкой ровно в одну секунду после одного и того же настенного якоря T, поэтому их нужно воспроизвести одновременно. Приёмник задерживает тот поток, который готов раньше – обычно это аудио, поскольку буферизация звука проста и не вызывает сбоев, – пока соответствующий видеокадр не будет декодирован и не станет готов к выводу, после чего оба потока выпускаются вместе. Если позже приёмник определит, что аудио стабильно опережает видео на 120 миллисекунд, он добавляет 120 миллисекунд задержки в аудиотракт, чтобы синхронизировать их в пределах перцептивного окна. Вся система зависит от точности сопоставления NTP-меток с метками RTP в этих Sender Report. Если такие пары будут повреждены или пропущены – арифметика выдаст неверный результат, но с полной уверенностью в своей правильности.
Почему SFU – это место, где ломается синхронизация губ
В звонке один-на-один Sender Report, которые формирует устройство захвата и читает приёмник, содержат честные временные метки момента захвата медиа – это NTP-времена. Именно поэтому синхронизация губ (lip-sync) в WebRTC один-на-один на практике является решённой задачей – она работает «из коробки».
Групповые звонки устроены иначе, потому что почти всегда проходят через selective forwarding unit – SFU, который принимает потоки от каждого участника и пересылает каждому только нужные. Ключевой момент в том, что SFU является RTCP-терминирующим посредником: он не пропускает RTCP-пакеты отправителя напрямую, а потребляет их и генерирует собственные Sender Report для каждого получателя. Причина в том, что при пересылке SFU переписывает временные метки RTP (чтобы поддерживать переключение потоков, смену слоёв simulcast и репакетизацию), из-за чего исходные пары NTP–RTP отправителя больше не соответствуют реально передаваемым пакетам.
Вот суть вопроса. Отображение NTP в RTP, которое строит приёмник, основано на часах SFU и его внутренней обработке, а не на данных исходного отправителя. Любая асимметрия в том, как SFU обрабатывает аудио и видео – например, разные очереди, логика переписывания или отчёт, отправленный для одного потока, но не для другого – сразу отражается в этом отображении, на которое полагается приёмник. В результате аудио и видео перестают синхронизироваться. SFU взял на себя роль «честных часов», и если он выполняет эту задачу небрежно, ошибка наследуется каждым приёмником.
Для этого существует современное решение, важное для всех трёх движков ниже. Проект WebRTC определяет расширение заголовка RTP под названием Absolute Capture Time, чья единственная цель, как сказано в спецификации, – «предоставить способ обеспечить аудио-видео синхронизацию при использовании RTCP-терминирующих промежуточных систем, таких как микшеры». Оно добавляет к отдельным RTP-пакетам NTP-метку момента, когда кадр был изначально захвачен, используя те же часы, что и устройство захвата для своих Sender Report. Если SFU корректно передаёт это расширение, приёмник может восстановить исходную временную метку захвата, даже если SFU изменил всё остальное. SFU, способный понимать и сохранять Absolute Capture Time, может поддерживать синхронизацию, которую потерял бы SFU, работающий только с отчётами. Посредник также может заменить метки захвата на свои, если хочет выдать себя за устройство захвата, но для пересылающего SFU оптимальное поведение – пропускать оригинальные метки без изменений.
Как это делает mediasoup
mediasoup – это низкоуровневая библиотека SFU, и она явно занимается переписыванием временных меток. Когда исходный поток потребителя переключается – например, при смене слоя simulcast участника или при замене продюсера на слот – mediasoup переписывает исходящие RTP-метки времени так, чтобы они оставались непрерывными, и вычисляет новую метку на основе связи, восстановленной по Sender Report от продюсеров. Иными словами, она использует входную привязку NTP к RTP, чтобы определить правильную исходящую метку, а не угадывает её. Это правильный подход, и именно поэтому системы на mediasoup обычно сохраняют синхронизацию при переключении слоёв, которые иначе нарушили бы поток.
Подвох в использовании библиотеки такого низкого уровня заключается в том, что за те части, которые mediasoup не берёт на себя, отвечает приложение. mediasoup будет передавать метки и отчёты, если их настроили на обработку, но разработчик должен сам позаботиться о том, чтобы временные метки для аудио и видео вычислялись одинаково, а необходимые расширения заголовков – включая Absolute Capture Time, где это уместно, – были согласованы и корректно передавались. mediasoup предоставляет правильные примитивы, но не защищает от ошибок при их несогласованной сборке. По нашему опыту, типичные проблемы с синхронизацией в mediasoup возникают не из-за самой библиотеки, а из-за кода приложения, который по-разному обрабатывает аудио- и видеопотоки.
Как это делает Pion
Pion – это реализация WebRTC на языке Go, лежащая в основе множества кастомных SFU и медиасерверов, включая исторически часть стека LiveKit. Pion предоставляет механизмы тайминга напрямую, не скрывая их. Его пакет rtcp определяет структуру SenderReport с явно указанными полями NTPTime и RTPTime, а фреймворк интерсепторов включает report-интерсептор, генерирующий Sender и Receiver Report на основе RTP и RTCP-пакетов, проходящих через него. Как отмечают мейнтейнеры Pion, Sender Report позволяет «связать два разных RTP-потока, используя RTPTime и NTPTime», чтобы вы «знали, в какое абсолютное время произошло то или иное событие», а затем «в вашем приложении воспроизведения можно буферизовать и выводить данные, обеспечивая их синхронность».
На практике это означает, что Pion – это набор инструментов, а не готовый продукт. Если вы строите SFU на Pion, логика синхронизации – ваша. Pion предоставляет правильные Sender Report и все необходимые компоненты для их генерации при пересылке, но решение о том, нужно ли согласовывать временные метки между аудио и видео, передавать Absolute Capture Time и регулярно отправлять Sender Report для обоих потоков – остаётся за вами. В этом и заключается сила, и одновременно ловушка Pion: ничего не скрыто, и ничего не сделано за вас. Команды, понимающие модель временных меток, строят на Pion отличную синхронизацию; команды, воспринимающие его как чёрный ящик, выпускают баги lip-sync.
Как это работает в LiveKit
LiveKit – полноценный SFU, изначально построенный на Pion, но задумывавшийся как готовый продукт, а не библиотека, и он берёт на себя синхронизацию. Его сервер анализирует NTP-метки в Sender Report пересылаемых треков и использует их, чтобы синхронизировать аудио и видео для каждого участника. Для большинства команд это оптимальный уровень абстракции – вы получаете стабильную синхронизацию, не занимаясь ручной обработкой временных меток.
Собственный issue-трекер LiveKit – самая честная иллюстрация хрупкости системы, и о сценариях её отказа стоит знать, потому что они типичны для любого SFU. В проекте описан случай, когда сервер перестал отправлять RTCP Sender Report для аудиотрека вниз по потоку, если использовался кодек RED – кодирование с избыточностью, повышающее устойчивость Opus к потере пакетов. Отсутствие аудио Sender Report означало отсутствие NTP-якоря для аудиопотока, из-за чего приёмник не мог привязать аудио к общей временной шкале, а значит, синхронизация губ (lip-sync) нарушалась. Отдельный issue, связанный с записью и egress, показал ту же проблему с другой стороны: Sender Report приходили для видеотрека, но не для аудиотрека, поэтому конвейер записи не мог синхронизировать оба потока. В обоих случаях проблема не была в логике синхронизации – она заключалась в отсутствии Sender Report, то есть в отсутствии самого входного сигнала, от которого эта логика зависит. И это важнейший урок, применимый к любому движку: качество синхронизации определяется не алгоритмами, а точностью и полнотой поступающих временных отчётов. А самый частый сбой в продакшене – это отчёт, который просто перестал приходить.
Две стратегии и три движка в сравнении
Читайте таблицу по одной строке. Первые две строки описывают два механизма меток времени, последние три – движки.
| Механизм / движок | Что даёт | Где может сломаться |
|---|---|---|
| RTCP Sender Report (RFC 3550) | Пара NTP-к-RTP на поток; классический якорь | SFU регенерирует его на своих часах; отсутствующий отчёт убивает синхронизацию для этого потока |
| Расширение Absolute Capture Time | NTP-время исходного захвата на пакетах, переживает RTCP-терминирующий SFU | Работает, только если каждый хоп согласует и пересылает расширение |
| mediasoup | Переписывает RTP-метки из NTP-связи SR при переключении потока | Приложение должно вести аудио/видео-тракты одинаково и согласовать нужные расширения |
| Pion | Правильные примитивы SR и report-интерсептор; полный контроль | Вся логика синхронизации на вас; ничто не делается автоматически |
| LiveKit | Серверная синхронизация по NTP-меткам SR треков; из коробки | Краевые случаи (например, RED-аудио, egress), где SR перестаёт отправляться, ломают синхронизацию |
Честный итог: на уровне протокола lip-sync хорошо определён, и стандартные механизмы работают. На уровне системы риск всегда один и тот же – RTCP-терминирующий SFU, который неидеально регенерирует тайминг или просто не отправляет Sender Report для одного из двух потоков. Расширение Absolute Capture Time – самая надёжная защита, поскольку позволяет исходному таймингу захвата пройти через SFU, но только при условии, что оно согласовано от конца до конца.
Частые ошибки, превращающие синхронизацию в баг
Тот же небольшой набор ошибок объясняет большинство жалоб на lip-sync в WebRTC, и каждая из них – результат непонимания, где находится тайминг.
Ошибка «разделить медиатракты». Самое разрушительное архитектурное решение – передавать аудио через один сервер, а видео – через другой, например, подключая видео-SFU к устаревшему MCU, который микширует аудио. У двух трактов теперь независимый, несвязанный тайминг, и нет общей точки отсчёта для синхронизации. Как прямо формулирует WebRTC-сообщество: переходите полностью на SFU или полностью на MCU, но никогда не разделяйте обработку аудио и видео между разными медиасерверами.
Ошибка «винить сеть». Когда синхронизация сбивается, инженеры сразу хватаются за анализ пакетов и графики джиттера. Но сетевой джиттер влияет на временные задержки симметрично и проявляется как заикание, а не как устойчивое одностороннее смещение. Ошибка синхронизации, которая наблюдается с самого начала звонка и предсказуемо ухудшается со временем, – это проблема часов или временных меток, а не джиттер; как правило, это сбой в Sender Report или накопленный дрейф часов.
Отсутствующий Sender Report. Как показывают собственные баги LiveKit, самая частая причина – SFU, который просто перестаёт отправлять Sender Report для одного потока при определённой конфигурации кодека и на определённом тракте. Аудио теряет NTP-якорь и начинает «плыть». Всегда проверяйте в chrome://webrtc-internals, что Sender Report приходят для обоих потоков – и аудио, и видео.
Непересылаемое расширение захвата. Команды используют Absolute Capture Time на стороне отправителя, полагая, что это обеспечивает синхронизацию через SFU, и не проверяют, действительно ли SFU передаёт это значение получателю. Расширение, отброшенное на первом же участке пути, не оказывает никакого эффекта. Оно должно быть согласовано и сохранено на каждом звене цепочки.
Где здесь Фора Софт
Мы строим медиатракт в платформах видеоконференций, системах вебинаров и e-learning, телемедицинских приложениях и продуктах живого стриминга с 2005 года, включая кастомные SFU на mediasoup и Pion, а также развёртывания на LiveKit. Lip-sync – одна из самых незаметных, но в то же время показательных частей этой работы: когда клиент сообщает, что «звук чуть впереди в групповых звонках, но один-на-один всё нормально», мы сразу понимаем, что проблема – не в кодеке и не в сети, а в том, как SFU генерирует Sender Report и передаёт ли он Absolute Capture Time. Почти всегда решение сводится к восстановлению честного временного якоря для обоих потоков – либо заставить SFU отправлять Sender Report для аудиотрека, который он молча отбрасывал, либо пропустить расширение захвата через каждый хоп.
Ключевые выводы
- Синхронизация губ (lip-sync) в WebRTC для однонаодного соединения решена; проблемы возникают при групповых звонках через SFU.
- RTP-метки фиксируют временные интервалы внутри потока, а RTCP Sender Report привязывают их к сетевому времени NTP.
- Общий CNAME позволяет приёмнику определить, какие аудио- и видеопотоки принадлежат одному и тому же участнику.
- SFU завершает RTCP и генерирует Sender Report на основе своих часов – это критическая точка риска.
- Absolute Capture Time добавляет к пакетам время первоначального захвата, чтобы сохранить синхронизацию при прохождении через SFU.
- Наиболее распространённая ошибка – отсутствие Sender Report для одного из потоков, а не сбой в алгоритме синхронизации.