Lip-sync в WebRTC: как это делают mediasoup, Pion и LiveKit

Автор: Николай СапуновОбновлено: август 202616 мин чтения
Содержание статьи +

Коротко

WebRTC удерживает голос говорящего согласованным с его губами, проставляя на каждый аудио- и видеопоток две разновидности меток времени – быструю метку медиа-часов на каждом пакете и редкое показание настенных часов в RTCP Sender Report, – а затем позволяя приёмнику выровнять оба потока относительно этих общих настенных часов. В звонке один-на-один это решённая задача, о которой вы никогда не думаете. Беда начинается в тот момент, когда в середине оказывается SFU (selective forwarding unit), потому что SFU терминирует RTCP отправителя и вынужден заново генерировать собственные Sender Report, – поэтому сохранят ли mediasoup, Pion или LiveKit ваш lip-sync, целиком зависит от того, насколько точно они переписывают эти метки времени и пересылают более новый механизм под названием Absolute Capture Time. Эта статья объясняет машинерию меток времени простыми словами, разбирает, как с ней обходится каждый из трёх движков, и показывает, где именно ломается синхронизация и как это заметить.

Почему это важно

Если вы строите продукт видеоконференций, платформу вебинаров, телемедицинское приложение или любую функцию групповых звонков на WebRTC, жалоба «звук чуть впереди видео» рано или поздно ложится вам на стол – и это почти никогда не то, на что грешат в первую очередь. Продакт-менеджеру нужно понимать, почему lip-sync, идеально работавший в демо на двоих, разваливается на встрече из пятидесяти человек, ведь это решение в архитектуре, а не баг, появившийся сам собой. Инженеру, выбирающему между mediasoup, Pion и LiveKit, нужно знать, кто из них правильно ведёт бухгалтерию меток времени и у кого где известные пробелы. Весь смысл lip-sync в том, что его никто не замечает; те, кто строит систему, – единственные, кому вообще приходится о нём думать, и думать им надо тщательно.

Рисунок 1. В звонке один-на-один тайминг отправителя доходит до приёмника нетронутым. SFU разрывает эту цепочку: он терминирует RTCP отправителя и вынужден заново генерировать Sender Report на своих часах – и именно здесь синхронизация идёт не так.

Сначала – что вообще требуется для «синхронности»

Прежде чем смотреть на любой движок, полезно чётко понять, что именно пытается сделать приёмник. Аудио и видео говорящего захватываются как два отдельных потока. Их кодируют два разных кодека – 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, чтобы понять, что вот этот аудиопоток и вот тот видеопоток принадлежат одному человеку и должны синхронизироваться друг с другом. Без него, в комнате, полной людей, каждый из которых шлёт аудио и видео, у приёмника не было бы надёжного способа понять, какой голос идёт с каким лицом.

Рисунок 2. Каждый поток несёт быстрые часы RTP; редкий Sender Report связывает эти часы с настенным временем NTP. Общий CNAME сообщает приёмнику, что два потока – это один человек, а связка NTP отображает оба на одну ось.

Разбор на числах: как из меток времени получается смещение синхронизации

Числа делают механизм наглядным, поэтому вот арифметика, которую гоняет приёмник.

Допустим, у приёмника есть аудио 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 timestamp 900 000 тоже соответствует NTP-времени T. Видео идёт на 90 000 Гц, поэтому видеокадр с RTP timestamp 990 000 находится на:

(990 000 − 900 000) ÷ 90 000 Гц = 90 000 ÷ 90 000 = 1,000 секунды после T

Оба попадают на одну секунду после одного и того же настенного якоря T, поэтому этот аудиопакет и этот видеокадр следует воспроизвести в одно мгновение. Приёмник задерживает тот поток, что готов раньше, – обычно он придерживает аудио, потому что буферизация аудио дешёвая и плавная, – пока соответствующий видеокадр не будет декодирован и готов, а затем выпускает их вместе. Если приёмник позже вычислит, что аудио стабильно попадает на 120 миллисекунд раньше своего видео, он добавляет 120 миллисекунд буферизации в аудиотракт, чтобы вернуть их в перцептивное окно. Вся схема стоит или падает на правильности пар NTP-к-RTP в этих Sender Report. Испортите или пропустите их – и арифметика выдаст неверный ответ с полной уверенностью.

Почему SFU – это место, где ломается lip-sync

В звонке один-на-один Sender Report, которые читает приёмник, написаны самим устройством захвата, поэтому NTP-времена – честные показания момента захвата медиа. Именно поэтому lip-sync WebRTC один-на-один на практике решённая задача – вы получаете её бесплатно.

Групповые звонки устроены иначе, потому что они почти всегда идут через selective forwarding unit, SFU, который принимает потоки каждого участника и пересылает те, что нужны каждому. Принципиально, что SFU – это RTCP-терминирующий посредник. Он не пропускает RTCP-пакеты отправителя насквозь. Он их потребляет и генерирует собственные Sender Report в адрес каждого приёмника – потому что он переписывает RTP timestamp при пересылке (чтобы обрабатывать переключение потоков, смену слоёв simulcast и репакетизацию), так что исходные пары NTP-к-RTP отправителя больше не совпали бы с реально уходящими пакетами.

Вот сердцевина вопроса. Отображение NTP-к-RTP, которое строит приёмник, основано на часах SFU и собственной обработке 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 timestamp так, чтобы они оставались непрерывными, и вычисляет новую метку из связи, восстановленной по Sender Report продюсеров. Иными словами, он использует входящую связку NTP-к-RTP, чтобы вычислить правильную исходящую, а не угадывает её. Это верный подход, и именно поэтому системы на mediasoup обычно удерживают синхронизацию через переключения слоёв, которые иначе тряхнули бы поток.

Подвох с библиотекой такого низкого уровня в том, что за части, которые mediasoup не навязывает, отвечает приложение. mediasoup перешлёт метки и отчёты, которые ему сконфигурировали обрабатывать, но разработчик должен убедиться, что аудио- и видеометки времени вычисляются одинаково и что нужные расширения заголовка, включая Absolute Capture Time там, где оно помогает, согласованы и пересылаются. mediasoup даёт вам правильные примитивы; он не защищает вас от их несогласованной сборки. По нашему опыту, типичный баг синхронизации на 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 TimeNTP-время исходного захвата на пакетах, переживает 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 для одного потока, а не плохая математика синхронизации.

Что почитать дальше

Строите такую систему?

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