MLow — голосовой кодек звонков WhatsApp *
- суббота, 26 сентября 2026 г. в 00:00:15

Когда вы звоните в WhatsApp, Messenger или Instagram*, ваш голос, скорее всего, сжимает не Opus, а MLow - собственный низкобитрейтный кодек Meta*, созданный для плохих сетей и слабых смартфонов. Компания рассказала о нём в инженерном блоге, но ни спецификации, ни исходников, ни описания формата кадра нет.
Эта статья о том, как мы восстановили кодек по его поведению и написали совместимую реализацию на Go без cgo и сторонних зависимостей. Сначала мы дважды ошиблись в том, что это вообще за кодек. Ещё были «электронные пищалки» в эфире, и под конец пришлось честно признать, что чужой код лучше нашего.
* Meta Platforms Inc. (владелец Facebook, Instagram, WhatsApp и Messenger) признана экстремистской организацией, её деятельность запрещена на территории РФ. Facebook и Instagram запрещены в РФ.
Дисклеймер. Статья о методологии, а не пошаговая инструкция. Мы намеренно не приводим таблицы, константы, смещения и детали транспортного уровня: они не нужны, чтобы понять ход работы, и лишние в публичном тексте. Исходный код Meta мы не использовали: всё восстановлено по наблюдаемому поведению библиотеки ради совместимости.
Нам нужен был клиент, который говорит на голосовом протоколе WhatsApp «как родной»: принимает звук от собеседника и отправляет свой так, чтобы его без искажений воспроизводил настоящий клиент. Для этого нужны декодер и кодер, совместимые с оригиналом.
Оригинальная библиотека нативная, работает под мобильными ОС и тянет за собой всю платформу. Нам же хотелось получить:
чистый Go - CGO_ENABLED=0, кросс-компиляция одной командой;
без зависимостей - только стандартная библиотека;
без чёрных ящиков - каждую стадию кодека можно прочитать, протестировать и отладить.
MLow - не лабораторный эксперимент, а кодек, через который ежедневно проходят звонки огромного числа людей. По данным инженерного блога Meta:
Instagram и Messenger - кодек полностью раскатан на все звонки;
WhatsApp - кодек постепенно внедряется, и, по нашим наблюдениям, основной голос в звонках один на один уже идёт через MLow;
медленные сети и слабые смартфоны - главный сценарий: речь остаётся разборчивой даже на битрейте около 6 кбит/с, а вычислительная сложность ниже, чем у Opus;
связь с потерями пакетов - за счёт низкого битрейта в тот же канал помещается избыточная копия кадров (FEC), и пропавший пакет удаётся восстановить.
Поэтому совместимость с MLow - не прихоть: чтобы полноценно звонить в экосистеме Meta и звучать «как родной» клиент, без неё не обойтись.
Из публичных источников было известно немного: кодек рассчитан на очень низкие битрейты, построен на идеях классических речевых кодеков (CELP) и должен звучать лучше Opus там, где сеть совсем плохая.
По устройству это форк libopus. Он переиспользует знакомый каркас: интервальный (range) кодер, байт-заголовок кадра в стиле Opus, общую инфраструктуру. Внутри при этом живут собственные режимы.

Если сильно упростить, на входе 60 мс речи на частоте 16 кГц, на выходе несколько десятков байт. Первый байт кадра говорит декодеру, какой режим внутри, а сверху кадр упакован в обёртку с избыточностью, чтобы переживать потери пакетов.
Самый поучительный кусок проекта - то, как менялось наше понимание, что именно мы реверсим.

Гипотеза 1: «Это SILK». Статический анализ библиотеки показывал огромное количество кода, очень похожего на SILK - речевую половину Opus. Логичный вывод: MLow - это доработанный SILK. Мы даже начали писать декодер и успели реализовать пару стадий.
Гипотеза 2: «Это чистый CELT». Потом появился живой оракул (о нём ниже), и оказалось, что на тех кадрах, которые у нас были, SILK-код вообще не выполняется. Кадры прекрасно декодирует почти стандартный CELT - трансформная, MDCT-половина Opus - с небольшими отличиями в конфигурации. Мы выбросили SILK-заготовку, переписали всё на CELT и за пару дней получили декодер, совпадающий с оригиналом бит-в-бит.
Реальность: гибрид. Когда мы подключились к настоящим звонкам, выяснилось, что CELT-кадры - лишь эпизодическая часть трафика. Основной голос передаётся в речевом режиме: это CELP-кодек, родственный SILK, но со своей обработкой сигнала. Направление, от которого мы отказались на втором шаге, снова стало главным - только теперь с пониманием, чем MLow отличается от SILK.
Урок: статический анализ показывает, какой код есть, а динамический - какой код работает. Для кодеков, которые часто оказываются форками с кучей мёртвого наследия, это принципиальная разница. И даже живой оракул говорит правду только про те данные, которые вы ему дали: наш первый корпус просто не содержал основного режима.
Главный инструмент проекта - оракул, то есть возможность прогнать один и тот же вход через оригинальную библиотеку и через нашу реализацию и сравнить не только итоговый звук, но и промежуточные состояния.

Кодек - это конвейер: энтропийное декодирование, энергии частотных полос, коэффициенты, синтез, постфильтры. Если на выходе «звучит не так», искать ошибку во всём конвейере бессмысленно. Мы снимали промежуточные данные на каждой стадии и искали первую, на которой наши значения расходятся с оригиналом. Всё, что до неё, заведомо верно, а всё, что после, может быть просто следствием.
По сути это git bisect, только по стадиям обработки сигнала, а не по коммитам.
Отдельно пришлось выбирать метрики:
для битового потока - только побайтовое совпадение: интервальный кодер не прощает ни одного лишнего бита, и после первой же ошибки дальше идёт мусор;
для звука - отношение сигнал/шум (SNR) и нормированная корреляция. Побайтовое совпадение PCM на плавающей точке - слишком жёсткое требование, а «на слух» - слишком мягкое.
Ещё одно правило, которое сэкономило нам недели: каждая гипотеза проверяется против оригинала, а не против здравого смысла. Несколько раз «очевидно правильное» значение, найденное статическим анализом, оказывалось неверным. Например, один параметр энтропийного кодирования мы по дизассемблеру посчитали изменённым по сравнению со стандартным CELT. Из-за этого энергии полос сходились, а коэффициенты превращались в кашу. Оракул быстро показал, что там стоит обычное стандартное значение.
CELT-часть сдалась быстрее всего: публичная эталонная реализация Opus хорошо документирована, а отличия MLow оказались в основном конфигурационными: другая геометрия кадра, моно, фиксированная длительность, другой способ выбора полосы.
Путь до полного совпадения на эталонном корпусе занял пять исправлений. Все пять нашлись описанным выше поиском по стадиям:
неверная догадка про параметр энтропийного кодирования (см. выше);
неправильная геометрия режима: сначала мы угадали частоту дискретизации и размер окна неверно;
постфильтр применялся не в том месте конвейера и не в той форме;
состояние генератора псевдослучайного «заполнения» полос не переносилось между кадрами;
некорректный переход, когда постфильтр выключается между кадрами.
После этого весь эталонный корпус декодировался с SNR около 80 дБ, то есть с точностью до округления в последнем бите 16-битного звука. Следом появились маскировка потерь пакетов (и она тоже совпала с оригиналом) и кодер, чьи пакеты оригинальный декодер воспроизводит практически идентично нашему.
С основным, речевым режимом всё оказалось сложнее. Энтропийную часть (какие биты что означают) мы довели до бит-в-бит на корпусе из тысячи с лишним пакетов. А вот синтез звука содержит собственную обработку Meta, которой нет в открытом Opus.
Первые живые звонки звучали как речь, в которую кто-то вставил короткие электронные писки. Причин оказалось две, и обе очень характерны для реверса.

Первая - время. Часть кадров на проводе - служебные: пауза, комфортный шум. Наш декодер принимал их за короткие кадры другого режима и выдавал в несколько раз меньше сэмплов, чем нужно. Каждый кадр по отдельности декодировался «правильно», но временна́я шкала сжималась, и речь шла рывками. На слух это невыносимо.
Вторая - путаница соглашений. В двух частях кода по-разному нумеровался тип кадра («вокализованный / невокализованный»). В одном месте мы взяли не тот признак, и невокализованные кадры синтезировались с таблицей усилений от вокализованных - в сотни раз громче, чем нужно. Фильтр уходил в насыщение, и получался тот самый писк.
Оба бага нашлись, когда мы перестали слушать и начали считать: сколько сэмплов выдаёт каждый кадр и в какой момент амплитуда упирается в ограничитель.
Похожая история случилась с упаковкой. Сначала мы решили, что кадры завёрнуты в стандартный многокадровый контейнер Opus: на наших данных эта модель подходила идеально. Позже выяснилось, что это совпадение для частного случая, а на самом деле там собственный слой избыточности для борьбы с потерями. На кадрах другого размера или с другим числом копий старая модель ломалась.
Урок: модель, которая объясняет все имеющиеся данные, ещё не обязательно верна. Нужны данные, на которых конкурирующие модели дают разные предсказания.
Параллельно с нами сообщество разобрало тот же речевой режим, и появилась открытая эталонная реализация. Мы провели честное A/B-сравнение на одних и тех же записях реальных звонков:
декодер: их версия заметно лучше на вокализованной речи, где решает точное воспроизведение предсказания основного тона. Наша -чуть лучше на невокализованной;
кодер: наш собственный кодер прошёл десять итераций настройки и на живых звонках звучал «достаточно хорошо». Но на вокализованной речи оставался едва слышный шум квантования, а эталонный кодер звучит как оригинал, без каких-либо артефактов.
Решение было болезненным, но очевидным. Побайтово совпадающий кодер, который на слух неотличим от оригинала, выигрывает у самописного по любому важному критерию. Мы перенесли эталонную реализацию в свой модуль (с сохранением авторства) - перенос совпадает с ней побайтово на всех 295 из 295 тестовых кадров -и сделали её основным путём отправки. На приёме по умолчанию пока остаётся наш декодер, а эталонный включается флагом: он точнее на вокализованной речи и примерно втрое быстрее.
Иногда лучший результат реверса - понять, что твоя версия не нужна.
Кодер обязан укладываться в реальное время с запасом: на одном сервере одновременно может идти много звонков. Первая версия тратила около 14 мс на кадр длиной 60 мс. Формально это укладывается в бюджет, но запас получался маленьким.

Главные выигрыши дали очень скучные вещи:
таблица поворотных множителей FFT - синусы и косинусы считались заново в каждой «бабочке», предвычисленная таблица дала больше чем двукратное ускорение;
пулы буферов - кодер выделял память на каждом подкадре, и число аллокаций на кадр упало с ~14 тысяч до ~360;
константные таблицы - некоторые окна и матрицы преобразований пересчитывались каждый кадр, хотя от входа не зависят.
Профилировщик подсказывал и ассемблер с SIMD, но от него мы сознательно отказались. Векторизация меняет порядок операций с плавающей точкой (FMA, суммирование деревом), а значит, и последние биты результата, и кодер перестал бы совпадать с оригиналом побайтово. К тому же SIMD ускорил бы только ~8 % времени. Алгоритмические правки дали на порядок больше и сохранили совместимость.
Каждый шаг оптимизации проверялся тем же способом, что и реверс: выход должен совпадать с эталоном байт в байт, иначе изменение откатывается.
модуль на чистом Go, без cgo и внешних зависимостей;
речевой режим: кодер, побайтово совпадающий с эталоном и проверенный на живых звонках; на приёме - собственный декодер и эталонный на выбор;
трансформный режим: декодер бит-в-бит с оригиналом, включая маскировку потерь; кодер, чьи пакеты оригинал воспроизводит практически без отличий;
разбор обёртки с избыточностью и служебных кадров;
кодирование примерно в 14 раз быстрее реального времени, эталонное декодирование - примерно в 270 раз.
Динамика важнее статики. Код в бинарнике не означает, что он работает. Форки обрастают мёртвым наследием.
Оракул - главный актив. Без возможности сравнить промежуточные состояния с оригиналом реверс сложного конвейера превращается в гадание.
Ищите первую расходящуюся стадию. Всё, что после неё, - шум.
Считайте, а не слушайте. Уши отлично находят проблемы и очень плохо их локализуют.
Проверяйте модели на данных, где они расходятся. Совпадение на имеющемся корпусе ещё ничего не доказывает.
Совместимость важнее скорости. Любая оптимизация, которая меняет биты на выходе, - регрессия, а не оптимизация.
Не влюбляйтесь в свой код. Если чужая реализация объективно лучше, берите её и честно указывайте авторство.
Если интересны подробности какой-то отдельной части (оракул, CELT, оптимизации на Go), напишите в комментариях: о чём-то из этого можно сделать отдельную статью.
* Meta Platforms Inc. (владелец Facebook, Instagram, WhatsApp и Messenger) признана экстремистской организацией, её деятельность запрещена на территории РФ. Facebook и Instagram запрещены в РФ.