В этой статье рассказывается о моем первом практическом опыте работы с WebAssembly и о нескольких полезных приемах, которые я получил при создании библиотеки vmsg.

Недавно у меня появилось немного свободного времени, поэтому я решил попробовать новый стандарт WebAssembly и реализовать с его помощью простую, но полезную библиотеку.

Как указано в документации WebAssembly, одной из основных целей было создание формата, который можно было бы быстро анализировать и который имел бы компактное представление кода. Так что мне было действительно любопытно воспользоваться этим главным преимуществом. У меня был опыт работы с asm.js в прошлом, когда я создавал порты для довольно сложного программного обеспечения C, такого как FFmpeg и кодировщики видео / аудио. Размеры сборки были ужасными - около 15 мегабайт минифицированного JavaScript для интерфейса командной строки ffmpeg с несколькими базовыми фильтрами и кодировщиками. Учитывая, что синтаксический анализ кода - это дорогостоящая операция, особенно на мобильных устройствах, он не выглядел столь практичным, скорее предназначенным для демонстрации концепций: webm.js, deviceframe.es.

С другой стороны, двоичный файл WebAssembly можно анализировать и компилировать так же быстро, как он поступает по сети, что делает его идеальной целью сборки для библиотек, написанных на C, которые вам нужно использовать внутри веб-страницы.

Идея библиотеки пришла довольно быстро: я провожу довольно много времени на веб-форумах, обсуждая разные вещи с помощью текстовых сообщений и изображений. В последнее время с появлением видео в формате HTML5 и форматов WebM / VPx стало обычным делом прикреплять к сообщениям небольшие видеоролики, что еще больше увеличивает возможности самовыражения. А как насчет голоса? Что, если вы можете буквально передать свое сообщение и отправить его как часть сообщения? Звучит отлично, давай попробуем!

Выбор архитектуры высокого уровня

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

В 2018 году Web Audio API получил широкую поддержку, проблем здесь нет. GetUserMedia с объединением ScriptProcessorNode способны на первый шаг, модуль WebAssembly будет отвечать за второй. Поскольку обратный вызов onaudioprocess узла ScriptProcessor выполняется в основном потоке, а также для того, чтобы интерфейс веб-страницы оставался отзывчивым, модуль WebAssembly будет создан в Web Worker, взаимодействуя с основным потоком через сообщения.

Боковое примечание: ScriptProcessNode устарел и скоро будет заменен на Audio Workers, но на данный момент он реализован только в Chrome 64+ за флагом, и для совместимости мы все равно должны использовать старый API в ближайшем будущем. Более того, поскольку мы обрабатываем образцы в воркере, не выводим их на динамики и можем использовать большой буфер, Worklet не требуется в нашем конкретном случае. ScriptProcessNode должен работать нормально, все, что ему нужно сделать, это отправить образцы в Web Worker, что является очень быстрой и легкой операцией.

Мы также собираемся создать простой интерфейс, который будет запрашивать разрешение на использование микрофона и отображать форму записи с кнопками запуска / остановки / закрытия. Ниже вы можете увидеть схематический обзор компонентов библиотеки:

Выбор формата

Теперь нам нужно решить, в какой аудиоформат мы будем кодировать полученные сэмплы. Предпосылки: он должен работать во всех браузерах, поддерживающих WebAssembly, он должен давать нормальное сжатие, он должен быть широко распространен на всех платформах.

Изначально я хотел купить Opus, потому что это лучшее, что вы можете использовать для сжатия речи. К сожалению, он не поддерживается элементом <audio> в Safari и Edge. Конечно, для этого есть разные обходные пути. Например, в Edge вы можете вручную загрузить файл Opus и воспроизвести его через MediaSource API. Также кажется возможным установить пакет Web Media Extension для полной поддержки. В Safari можно использовать декодеры Opus, портированные на JavaScript, такие как ogv.js.

Хотя, на мой взгляд, это может быть слишком непрактично для реального использования. Можно сказать: «Если вы хотите поддерживать голосовые сообщения, добавьте эту библиотеку в свой проект». Но теперь вы диктуете, какой проигрыватель использовать для прослушивания результирующего аудио, или требуете некоторого нетривиального кода для обработки воспроизведения. Мне это не нравится, поэтому мне пришлось отказаться от Opus. Может быть, через несколько лет выбор станет намного проще.

Боковое примечание: Chrome и Firefox поддерживают MediaStream Recording API и могут кодировать MediaStream данные с помощью кодека Opus прямо из коробки. Но не в Safari и Edge, и я действительно хочу, чтобы моя библиотека работала во всех 4 из них, так что здесь снова не повезло.

Далее, есть формат WAV / PCM, доступный во всех браузерах. Создание WAV файла из сырых сэмплов - чрезвычайно простой процесс, для этого уже есть библиотека. Однако у него есть один маленький недостаток: сжатие вообще отсутствует. Итак, поете ли вы какую-нибудь красивую песню в микрофон или молчите, 30 секунд записи (48 кГц / моно) всегда будут весить ровно 2,7 мегабайта. Это слишком расточительно.

А как насчет MP3? Он везде поддерживается, имеет приличное сжатие и отличный LAME encoder. Исторически проекты FOSS отказались от его использования из-за патентов на программное обеспечение, но все они истекли в прошлом году. Похоже, у нас есть победитель.

Также есть AAC и Vorbis, но ни один из них не подходит. Бывший запрещает распространение реализации кодека в бинарной форме, которым фактически будет наш модуль WebAssembly. (Также сомнительно, что бесплатные реализации так же хороши, как проприетарные.) Последний не подходит для сжатия речи.

Компиляция

Итак, нам нужно взять кодировщик LAME, скомпилировать его в модуль WebAssembly и сделать возможным его использование из JavaScript.

Существует множество портов asm.js для LAME и, возможно, даже портов WASM, но я решил создать новый с нуля, чтобы сосредоточиться на оптимизации размера сборки.

Сначала я зеркально отразил репозиторий SVN с помощью git-svn, потому что предыдущее полуофициальное зеркало по какой-то причине устарело и не содержит последней версии 3.100, которая может содержать некоторые полезные исправления.

Для компиляции мы используем стандартный де-факто Emscripten toolchain, здесь ничего нового. Он активно разрабатывался в течение многих лет и предназначен для переноса библиотек C / C ++ в Интернет, а это именно то, что нам нужно. Не буду вдаваться в подробности, подробнее про Emscripten вы можете прочитать на официальном сайте.

Компилятор asm.js от Emscripten работает на базе LLVM под названием fastcomp. Для WebAssembly у вас есть два варианта: сначала скомпилировать в asm.js и преобразовать в WASM с помощью Binaryen. Или используйте внутреннюю структуру WebAssembly LLVM, которая способна самостоятельно создавать двоичные файлы WebAssembly (почти, вам все равно нужно использовать Binaryen на последнем этапе). Я выбрал второй, потому что в ближайшем будущем он станет предпочтительным. Также Emscripten недавно получил поддержку стандартного компоновщика LLVM, который вскоре снова станет предпочтительным.

Примечание: я не буду описывать процесс компиляции LLVM с бэкэндом WASM. Обычно рекомендуется использовать последнюю версию SVN. Вы можете проверить эту суть как отправную точку. Также возможно скомпилировать серверную часть WASM с emsdk, указав флаг --enable-wasm, но он использует довольно старый LLVM (основа для патчей fastcomp), поэтому результирующий модуль может быть больше / медленнее, чем с SVN LLVM. Он также не создает LLD.

Создадим заглушку нашей библиотеки. Я буду использовать команды оболочки Linux, YMMV.

$ cd ~
$ git init vmsg && cd vmsg
$ npm init -y

Теперь нам нужны исходники кодировщика LAME, для этого действительно пригодятся подмодули git:

$ git submodule add https://github.com/Kagami/lame-svn.git
$ cd lame-svn && git checkout RELEASE_ScriptProcessNode100 && cd ..

Все идет нормально. Давайте скомпилируем libmp3lame.so (общая библиотека LAME), чтобы позже мы могли вызывать ее функции из модуля WebAssembly. Я использую GNU Makefile, хотя современные сборщики, такие как webpack и parcel, получают поддержку WASM, потому что он еще не развит, и я хочу поэкспериментировать с флагами компилятора и другими оптимизациями. И строители здесь только будут стоять на пути.

Создайте Makefile со следующим текстом (обязательно используйте табуляцию для отступов):

export EMCC_WASM_BACKEND = 1
export EMCC_EXPERIMENTAL_USE_LLD = 1

lame-svn/lame/dist/lib/libmp3lame.so:
	cd lame-svn/lame && \
	git reset --hard && \
	patch -p2 < ../../lame-svn.patch && \
	emconfigure ./configure \
		CFLAGS="-DNDEBUG -Oz" \
		--prefix="$$(pwd)/dist" \
		--host=x86-none-linux \
		--disable-static \
		\
		--disable-gtktest \
		--disable-analyzer-hooks \
		--disable-decoder \
		--disable-frontend \
		&& \
	emmake make -j8 && \
	emmake make install

Я сказал Emscripten использовать бэкэнд WASM и LLD, включил расширенную оптимизацию размера сжатия, отключил утверждения и отключил некоторые лишние вещи в LAME, которые нам не нужны. Патч исправляет проверку strtol в скрипте конфигурации и отключает стандартные репортеры LAME для уменьшения размера сборки (в противном случае Emscripten будет включать реализацию функции printf и другие вещи).

$ source /path/to/emsdk/emsdk_env.sh
$ make

Это активирует среду Emscripten и создаст библиотеку LAME в lame-svn/lame/dist/lib/ каталоге.

Теперь нам нужно использовать функции библиотеки LAME в модуле WebAssembly и экспортировать процедуры создания MP3, чтобы их можно было вызывать из JavaScript. Я не буду здесь вдаваться в подробности, вы можете посмотреть получившийся vmsg.c. У него есть 4 метода: init, encode, flush и free, которые информативны и вызывают один или несколько соответствующие LAME функции внутри. Для обмена данными с помощью JavaScript используется простая структура vmsg, в которой хранится текущее состояние кодирования. Также возможно кодировать несколько файлов параллельно, потому что у нас нет глобальных переменных.

Давайте наконец скомпилируем наш модуль WebAssembly. Добавьте это в Makefile:

vmsg.wasm: lame-svn/lame/dist/lib/libmp3lame.so vmsg.c
	emcc $^ \
		-DNDEBUG -Oz --llvm-lto 3 \
		-Ilame-svn/lame/dist/include \
		-s WASM=1 \
		-s "EXPORTED_FUNCTIONS=['_vmsg_init','_vmsg_encode','_vmsg_flush','_vmsg_free']" \
		-o _vmsg.js
	cp _vmsg.wasm $@

Здесь ничего особенного не озадачило, мы просим Emscripten скомпилировать нашу оболочку C, объединить ее с общей библиотекой LAME и экспортировать функции, которые нам понадобятся на стороне JavaScript.

Введите make vmsg.wasm и все. Мы перенесли в Интернет полнофункциональный кодировщик MP3, который весит всего около 70 КБ в сжатом виде:

$ wc -c < vmsg.wasm
152799
$ gzip -6 -c vmsg.wasm | wc -c
74152

Обратите внимание, что 70 КБ сжатого с помощью gzip кода WebAssembly даже не похоже на 70 КБ сжатого с помощью gzip кода JavaScript с точки зрения сложности синтаксического анализа. Это похоже на небольшой образ: модуль WASM будет скомпилирован и готов к использованию примерно сразу после его загрузки. Возможно, можно было бы уменьшить размер модуля еще больше, но пока я доволен цифрами.

Время выполнения

JavaScript API для загрузки и вызова модуля WebAssembly довольно прост. Сложная часть здесь - предоставить функции, необходимые модулю для правильной работы на веб-платформе. Спецификация WebAssembly не определяет какую-либо стандартную библиотеку, как в C, которая отвечает за распределение памяти, математические операции, API ввода / вывода и так далее. И LAME без некоторых из них не будет работать. Emscripten использует исправленную облегченную стандартную библиотеку musl на стороне WASM (поэтому переносимые библиотеки не нужно переписывать) и генерирует модуль-оболочку в JS, который будет работать в тандеме с musl и вызывать, например, in-browser Date, чтобы функции даты и времени в musl могли работать правильно. К сожалению, за это приходится платить: даже после минимизации библиотекой Closure он будет весить около 10 КБ, поэтому мне было любопытно, смогу ли я сделать работу лучше, чем Emscripten, в моем конкретном случае.

Давайте сначала посмотрим, какой модуль на самом деле нужен с wasm-dis из Binaryen toolchain:

$ wasm-dis vmsg.wasm | grep '(import'
 (import "env" "memory" (memory $0 3))
 (import "env" "pow" (func $import$1 (param f64 f64) (result f64)))
 (import "env" "exit" (func $import$2 (param i32)))
 (import "env" "powf" (func $import$3 (param f32 f32) (result f32)))
 (import "env" "exp" (func $import$4 (param f64) (result f64)))
 (import "env" "sqrtf" (func $import$5 (param f32) (result f32)))
 (import "env" "cos" (func $import$6 (param f64) (result f64)))
 (import "env" "log" (func $import$7 (param f64) (result f64)))
 (import "env" "sin" (func $import$8 (param f64) (result f64)))
 (import "env" "sbrk" (func $import$9 (param i32) (result i32)))

Всего 10 функций, и большинство из них могут быть напрямую отображены на Math объект! Существует также exit, который вызывается, когда модуль решил, ну, выйти, memory, который является виртуальной памятью и создается с помощью new WebAssembly.Memory и sbrk, вызываемых musl, когда ему нужно выделить больше памяти. Здесь вы можете увидеть мою реализацию всех этих функций, которая занимает всего 30 строк и отлично работает.

Полифилл

Замечательно, что WebAssembly поддерживается всеми 4 основными браузерами (Chrome / Firefox / Safari / Edge), но не все пользователи Интернета имеют доступ к последним версиям браузеров. Поэтому разумно сделать так, чтобы ваше приложение поддерживало как можно больше версий, если это не сильно ухудшает удобочитаемость / производительность / обслуживание и т. Д. Например, я намеренно использую XHR в браузерах без WebAssembly.instantiateStreaming, потому что он удлиняет код всего на 5 строк и позволяет поддерживать браузеры без Fetch API, например. Край 12–14.

Прямо сейчас рекомендуемый способ «полифиллинга» WebAssembly - это создать отдельную сборку asm.js из одного и того же кода. Он очень хорошо работает со средой выполнения Emscripten, поскольку абстрагирует различия между этими двумя технологиями и предоставляет единый Module интерфейс для взаимодействия со скомпилированным кодом. Поскольку мы используем нашу собственную среду выполнения и потому, что кажется более естественным использовать API WebAssembly, если он доступен, и имитировать его, если нет, я решил встать на путь «истинного полифила».

Быстрый поиск по запросу WebAssembly polyfill вернул несколько проектов, самый многообещающий из них - by Ryan Kelly. Он работает путем эмуляции API-интерфейсов браузера WebAssembly, таких как WebAssembly.Memory и WebAssembly.Table, синтаксического анализа двоичного модуля и генерации кода, подобного asm.js, на лету. Именно то, что я хотел! К сожалению, он больше не поддерживался, поэтому мне пришлось его разветвить, немного реорганизовать, исправить очевидные проблемы и тесты и опубликовать в NPM. Самая ужасная ошибка была в генерации кода инструкции i64.store, но в конце концов я ее исправил. Вот мой форк, думаю, он может пригодиться и для других проектов.

Я также нашел polyfill в репозитории Binaryen, но он был слишком огромным (2,5 МБ против 95 КБ в случае wasm-polyfill) и неполным: он не эмулирует API браузера WebAssembly. Наконец, официальный прототип полифилла выглядит заброшенным, так что wasm-polyfill, вероятно, лучший вариант, который у нас есть сейчас. Однако это не идеально: сгенерированный код не так эффективен, как мог бы, создается множество дополнительных проверок привязки, чтобы быть полностью семантически правильным. См. Последний раздел о возможных улучшениях в этой области.

Использовать полифил очень просто: включить минифицированную wasm-polyfill.js сборку с тегом <script> или вызвать importScripts в случае Worker. Кусок торта.

Собираем все вместе

У нас уже есть модуль WebAssembly, можем загрузить его из JavaScript и вызвать его функции, что еще? Нам нужно создать его в Worker, определить протокол связи и передать ему реальные данные с микрофона.

Также нам нужно создать пользовательский интерфейс, чтобы пользователям не приходилось все время заново его реализовывать. Сначала я склонялся к React, потому что это чрезвычайно популярная и мощная библиотека для создания составляемых компонентов пользовательского интерфейса. Однако за это приходится платить: не все в мире используют React, например. Angular и Vue.js также широко распространены, и, придерживаясь React-only, вы оставляете множество потенциальных пользователей своей библиотеки за пределами. Учитывая, что я планировал сделать интерфейс довольно простым, React здесь не сильно поможет, поэтому лучше использовать стандартный DOM API. Более того, всегда можно включить такую ​​библиотеку на сайт, работающий на любом фреймворке, но не наоборот.

Я не буду аннотировать весь код, который у меня есть, большая его часть информативна. Посмотрите получившийся vmsg.js. Взаимодействие с веб-аудио и веб-воркерами уже достаточно хорошо задокументировано в Интернете. Единственная интересная часть заключается в том, что я не использую отдельный файл для источника worker, а вместо этого создаю Blob URL. Это делает библиотеку более приятной в использовании: вам не нужно заботиться о лишнем файле.

Полная демоверсия доступна здесь.

Будущие идеи

Что теперь? Библиотека работает неплохо, я уже использую ее на своем форуме для поддержки голосовых сообщений. Но есть еще несколько интересных областей, которые стоит попробовать:

  1. Emscripten скоро получит поддержку emmalloc, которая должна уменьшить размер сборки без каких-либо усилий, просто включив ее с помощью опции. За это приходится платить за то, что операции malloc становятся менее эффективными, но для LAME это не должно быть проблемой.
  2. Было бы интересно реализовать некоторые звуковые фильтры, такие как подавление шума и сдвиг высоты тона, как часть модуля WebAssembly. Поскольку он выполняется в отдельном рабочем потоке, мы можем использовать расширенные алгоритмы, не опасаясь возникновения задержки и замораживания основного потока.
  3. Стоит сравнить производительность wasm-polyfill с проверками границ памяти и без них. Поскольку LAME, как правило, не следует перехватывать, достаточно безопасно просто отключить все проверки для повышения производительности. Даже в случае ошибки в коде C это не повлияет на сторону JavaScript из-за песочницы.
  4. Довольно сложная, но выполнимая задача - сделать код, транслируемый с помощью wasm-polyfill asm.js-совместимым. Прямо сейчас есть много нарушений спецификаций, поэтому он не может быть скомпилирован AOT. Он работает как обычный JavaScript, поэтому не так быстро и производительно (хотя JIT в любом случае должен оптимизировать такой код).