Шаблон проектирования аудио-ворлета

В предыдущей статье о Audio Worklet подробно описаны основные понятия и их использование. С момента его запуска в Chrome 66 было много запросов на дополнительные примеры того, как его можно использовать в реальных приложениях. Audio Worklet раскрывает весь потенциал WebAudio, но воспользоваться его преимуществами может быть непросто, поскольку требуется понимание параллельного программирования с использованием нескольких API JS. Даже для разработчиков, знакомых с WebAudio, интеграция Audio Worklet с другими API (например, WebAssembly) может оказаться сложной задачей.

Эта статья даст читателю лучшее понимание того, как использовать Audio Worklet в реальных условиях, и предложит советы, как максимально использовать его возможности. Обязательно ознакомьтесь с примерами кода и живыми демонстрациями !

Резюме: аудио-работа

Прежде чем углубиться, давайте кратко резюмируем термины и факты, касающиеся системы Audio Worklet, которая была ранее представлена ​​в этом посте .

  • BaseAudioContext : основной объект API веб-аудио.
  • Audio Worklet : специальный загрузчик файлов сценариев для работы Audio Worklet. Принадлежит BaseAudioContext. BaseAudioContext может иметь один аудиоворлет. Загруженный файл сценария оценивается в AudioWorkletGlobalScope и используется для создания экземпляров AudioWorkletProcessor.
  • AudioWorkletGlobalScope : специальная глобальная область JS для операции Audio Worklet. Запускается в выделенном потоке рендеринга для WebAudio. BaseAudioContext может иметь один AudioWorkletGlobalScope.
  • AudioWorkletNode : AudioNode, предназначенный для работы Audio Worklet. Создан из BaseAudioContext. BaseAudioContext может иметь несколько AudioWorkletNodes, аналогично собственным AudioNodes.
  • AudioWorkletProcessor : аналог AudioWorkletNode. Фактические возможности AudioWorkletNode, обрабатывающие аудиопоток с помощью кода, предоставленного пользователем. Он создается в AudioWorkletGlobalScope при создании AudioWorkletNode. AudioWorkletNode может иметь один соответствующий AudioWorkletProcessor.

Шаблоны проектирования

Использование Audio Worklet с WebAssembly

WebAssembly — идеальный компаньон для AudioWorkletProcessor. Сочетание этих двух функций дает множество преимуществ при обработке звука в Интернете, но двумя самыми большими преимуществами являются: а) включение существующего кода обработки звука C/C++ в экосистему WebAudio и б) избежание накладных расходов на JS JIT-компиляцию и сбор мусора в коде обработки звука.

Первое важно для разработчиков, уже вложивших средства в код и библиотеки обработки звука, а второе имеет решающее значение практически для всех пользователей API. В мире WebAudio временной бюджет стабильного аудиопотока весьма требователен: он составляет всего 3 мс при частоте дискретизации 44,1 кГц. Даже небольшой сбой в коде обработки звука может вызвать сбои. Разработчик должен оптимизировать код для более быстрой обработки, а также минимизировать количество генерируемого JS-мусора. Использование WebAssembly может быть решением, которое решает обе проблемы одновременно: это быстрее и не генерирует мусор из кода.

В следующем разделе описывается, как WebAssembly можно использовать с Audio Worklet, а сопровождающий пример кода можно найти здесь . Базовое руководство по использованию Emscripten и WebAssembly (особенно связующего кода Emscripten) можно найти в этой статье .

Настройка

Все это звучит здорово, но нам нужно немного структуры, чтобы все правильно настроить. Первый вопрос проектирования, который следует задать, — это как и где создать экземпляр модуля WebAssembly. После получения связующего кода Emscripten существует два пути создания экземпляра модуля:

  1. Создайте экземпляр модуля WebAssembly, загрузив связующий код в AudioWorkletGlobalScope с помощью audioContext.audioWorklet.addModule() .
  2. Создайте экземпляр модуля WebAssembly в основной области, затем передайте модуль с помощью параметров конструктора AudioWorkletNode.

Решение во многом зависит от вашего дизайна и предпочтений, но идея состоит в том, что модуль WebAssembly может генерировать экземпляр WebAssembly в AudioWorkletGlobalScope, который становится ядром обработки звука в экземпляре AudioWorkletProcessor.

Шаблон создания экземпляра модуля WebAssembly A: использование вызова .addModule()
Шаблон создания экземпляра модуля WebAssembly A: использование вызова .addModule()

Чтобы шаблон A работал правильно, Emscripten необходимо несколько параметров для создания правильного связующего кода WebAssembly для нашей конфигурации:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

Эти параметры обеспечивают синхронную компиляцию модуля WebAssembly в AudioWorkletGlobalScope. Он также добавляет определение класса AudioWorkletProcessor в mycode.js , чтобы его можно было загрузить после инициализации модуля. Основная причина использования синхронной компиляции заключается в том, что разрешение обещаний audioWorklet.addModule() не ожидает разрешения обещаний в AudioWorkletGlobalScope. Синхронная загрузка или компиляция в основном потоке обычно не рекомендуется, поскольку она блокирует другие задачи в том же потоке, но здесь мы можем обойти это правило, поскольку компиляция происходит в AudioWorkletGlobalScope, который запускается вне основного потока. (См. это для получения дополнительной информации.)

Шаблон создания экземпляра модуля WASM B: использование межпоточной передачи конструктора AudioWorkletNode
Шаблон создания экземпляра модуля WASM B: использование межпоточной передачи конструктора AudioWorkletNode

Схема Б может быть полезна, если требуется асинхронный подъем тяжестей. Он использует основной поток для получения связующего кода с сервера и компиляции модуля. Затем он передаст модуль WASM через конструктор AudioWorkletNode. Этот шаблон имеет еще больший смысл, когда вам нужно динамически загружать модуль после того, как AudioWorkletGlobalScope начинает рендеринг аудиопотока. В зависимости от размера модуля его компиляция в середине рендеринга может вызвать сбои в потоке.

Куча WASM и аудиоданные

Код WebAssembly работает только с памятью, выделенной в выделенной куче WASM. Чтобы воспользоваться этим преимуществом, аудиоданные необходимо клонировать между кучей WASM и массивами аудиоданных. Класс HeapAudioBuffer в примере кода прекрасно справляется с этой операцией.

Класс HeapAudioBuffer для упрощения использования кучи WASM.
Класс HeapAudioBuffer для упрощения использования кучи WASM.

Обсуждается одно из первых предложений по интеграции кучи WASM непосредственно в систему Audio Worklet. Избавление от этого избыточного клонирования данных между памятью JS и кучей WASM кажется естественным, но необходимо проработать конкретные детали.

Обработка несоответствия размера буфера

Пара AudioWorkletNode и AudioWorkletProcessor предназначена для работы как обычный AudioNode; AudioWorkletNode обрабатывает взаимодействие с другими кодами, а AudioWorkletProcessor занимается внутренней обработкой звука. Поскольку обычный AudioNode обрабатывает 128 кадров одновременно, AudioWorkletProcessor должен делать то же самое, чтобы стать основной функцией. Это одно из преимуществ конструкции Audio Worklet, которое гарантирует отсутствие дополнительных задержек из-за внутренней буферизации в AudioWorkletProcessor, но это может стать проблемой, если функция обработки требует размера буфера, отличного от 128 кадров. Общим решением в таком случае является использование кольцевого буфера, также известного как кольцевой буфер или FIFO.

Вот диаграмма AudioWorkletProcessor, использующая два кольцевых буфера внутри для размещения функции WASM, которая принимает и выводит 512 кадров. (Число 512 здесь выбрано произвольно.)

Использование RingBuffer внутри метода `process()` AudioWorkletProcessor
Использование RingBuffer внутри метода `process()` AudioWorkletProcessor

Алгоритм построения диаграммы будет таким:

  1. AudioWorkletProcessor помещает 128 кадров во входной кольцевой буфер из своего входного файла.
  2. Выполняйте следующие шаги только в том случае, если входной кольцевой буфер больше или равен 512 кадрам.
    1. Извлеките 512 кадров из входного RingBuffer .
    2. Обработать 512 кадров заданной функцией WASM.
    3. Отправьте 512 кадров в Output RingBuffer .
  3. AudioWorkletProcessor извлекает 128 кадров из Output RingBuffer , чтобы заполнить его Output .

Как показано на диаграмме, входные кадры всегда накапливаются во входном кольцевом буфере, и он обрабатывает переполнение буфера, перезаписывая самый старый блок кадров в буфере. Это вполне разумно для аудиоприложения в реальном времени. Аналогично, блок выходного кадра всегда будет извлекаться системой. Недополнение буфера (недостаточно данных) в Output RingBuffer приведет к тишине, вызывающей сбой в потоке.

Этот шаблон полезен при замене ScriptProcessorNode (SPN) на AudioWorkletNode. Поскольку SPN позволяет разработчику выбирать размер буфера от 256 до 16384 кадров, замена SPN на AudioWorkletNode может оказаться затруднительной, и использование кольцевого буфера является хорошим обходным решением. Аудиомагнитофон будет отличным примером того, что можно построить на основе этой конструкции.

Однако важно понимать, что такая конструкция лишь устраняет несоответствие размера буфера и не дает больше времени для запуска данного кода сценария. Если код не может завершить задачу в пределах временного бюджета кванта рендеринга (~3 мс при 44,1 кГц), это повлияет на время начала последующей функции обратного вызова и в конечном итоге приведет к сбоям.

Совмещение этой конструкции с WebAssembly может быть затруднено из-за управления памятью вокруг кучи WASM. На момент написания данные, входящие и исходящие из кучи WASM, должны быть клонированы, но мы можем использовать класс HeapAudioBuffer, чтобы немного упростить управление памятью. Идея использования выделяемой пользователем памяти для уменьшения избыточного клонирования данных будет обсуждаться в будущем.

Класс RingBuffer можно найти здесь .

WebAudio Powerhouse: аудио-ворлет и SharedArrayBuffer

Последний шаблон проектирования в этой статье — объединение нескольких передовых API в одном месте; Аудио-ворлет, SharedArrayBuffer , Atomics и Worker . Благодаря этой нетривиальной настройке он открывает путь для существующего аудио-программного обеспечения, написанного на C/C++, для запуска в веб-браузере, сохраняя при этом удобство работы с пользователем.

Обзор последнего шаблона проектирования: Audio Worklet, SharedArrayBuffer и Worker.
Обзор последнего шаблона проектирования: Audio Worklet, SharedArrayBuffer и Worker.

Самым большим преимуществом этой конструкции является возможность использовать DedicatedWorkerGlobalScope исключительно для обработки звука. В Chrome WorkerGlobalScope выполняется в потоке с более низким приоритетом, чем поток рендеринга WebAudio, но у него есть несколько преимуществ перед AudioWorkletGlobalScope . DedicatedWorkerGlobalScope менее ограничен с точки зрения поверхности API, доступной в области действия. Также вы можете ожидать лучшей поддержки от Emscripten, поскольку Worker API существует уже несколько лет.

SharedArrayBuffer играет решающую роль в эффективной работе этой конструкции. Хотя и Worker, и AudioWorkletProcessor оснащены асинхронным обменом сообщениями ( MessagePort ), он неоптимален для обработки звука в реальном времени из-за повторяющегося выделения памяти и задержки обмена сообщениями. Поэтому мы заранее выделяем блок памяти, к которому могут обращаться оба потока для быстрой двунаправленной передачи данных.

С точки зрения приверженца веб-аудио API, этот дизайн может выглядеть неоптимальным, поскольку он использует Audio Worklet в качестве простого «приемника звука» и делает все в Worker. Но учитывая, что стоимость переписывания проектов C/C++ на JavaScript может быть непомерно высокой или даже невозможной, такой дизайн может оказаться наиболее эффективным способом реализации таких проектов.

Общие состояния и атомы

При использовании общей памяти для аудиоданных доступ с обеих сторон должен быть тщательно скоординирован. Совместное использование атомарно доступных состояний является решением такой проблемы. Для этой цели мы можем воспользоваться преимуществами Int32Array поддерживаемого SAB.

Механизм синхронизации: SharedArrayBuffer и Atomics.
Механизм синхронизации: SharedArrayBuffer и Atomics.

Механизм синхронизации: SharedArrayBuffer и Atomics.

Каждое поле массива States представляет важную информацию об общих буферах. Самое важное — это поле для синхронизации ( REQUEST_RENDER ). Идея состоит в том, что Worker ожидает, пока AudioWorkletProcessor коснется этого поля, и обработает звук после пробуждения. Наряду с SharedArrayBuffer (SAB), API Atomics делает этот механизм возможным.

Обратите внимание, что синхронизация двух потоков довольно свободна. Начало Worker.process() будет инициировано методом AudioWorkletProcessor.process() , но AudioWorkletProcessor не ждет завершения Worker.process() . Это сделано намеренно; AudioWorkletProcessor управляется обратным вызовом звука, поэтому его нельзя блокировать синхронно. В худшем случае аудиопоток может пострадать из-за дублирования или выпадения, но в конечном итоге он восстановится, когда производительность рендеринга стабилизируется.

Настройка и запуск

Как показано на схеме выше, в этой конструкции необходимо организовать несколько компонентов: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer и основной поток. Следующие шаги описывают, что должно произойти на этапе инициализации.

Инициализация
  1. [Основное] Вызывается конструктор AudioWorkletNode.
    1. Создать работника.
    2. Будет создан связанный AudioWorkletProcessor.
  2. [DWGS] Worker создает 2 SharedArrayBuffer. (один для общих состояний, а другой для аудиоданных)
  3. [DWGS] Worker отправляет ссылки SharedArrayBuffer в AudioWorkletNode.
  4. [Основная] AudioWorkletNode отправляет ссылки SharedArrayBuffer в AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor уведомляет AudioWorkletNode о завершении установки.

После завершения инициализации начинает вызываться AudioWorkletProcessor.process() . Ниже показано, что должно происходить на каждой итерации цикла рендеринга.

Цикл рендеринга
Многопоточный рендеринг с помощью SharedArrayBuffers
Многопоточный рендеринг с помощью SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) вызывается для каждого такта рендеринга.
    1. inputs будут помещены во входной SAB .
    2. outputs будут заполнены путем потребления аудиоданных в Output SAB .
    3. Соответствующим образом обновляет состояния SAB новыми индексами буфера.
    4. Если выходной SAB приближается к порогу нижнего предела, Wake Worker для рендеринга большего количества аудиоданных.
  2. [DWGS] Worker ожидает (спит) сигнала пробуждения от AudioWorkletProcessor.process() . Когда он просыпается:
    1. Извлекает индексы буфера из States SAB .
    2. Запустите функцию процесса с данными из входного SAB , чтобы заполнить выходной SAB .
    3. Соответственно обновляет состояния SAB с индексами буфера.
    4. Засыпает и ждет следующего сигнала.

Пример кода можно найти здесь, но учтите, что для работы этой демонстрации необходимо включить экспериментальный флаг SharedArrayBuffer. Для простоты код был написан на чистом JS-коде, но при необходимости его можно заменить кодом WebAssembly. К такому случаю следует относиться с особой осторожностью, обернув управление памятью классом HeapAudioBuffer .

Заключение

Конечная цель Audio Worklet — сделать API веб-аудио действительно «расширяемым». На его разработку ушли многолетние усилия, позволяющие реализовать остальную часть API веб-аудио с помощью Audio Worklet. В свою очередь, теперь мы имеем более высокую сложность его конструкции и это может стать неожиданной проблемой.

К счастью, причина такой сложности заключается исключительно в расширении возможностей разработчиков. Возможность запуска WebAssembly на AudioWorkletGlobalScope открывает огромный потенциал для высокопроизводительной обработки звука в Интернете. Для крупномасштабных аудиоприложений, написанных на C или C++, использование Audio Worklet с SharedArrayBuffers и Workers может быть привлекательным вариантом для изучения.

Кредиты

Особая благодарность Крису Уилсону, Джейсону Миллеру, Джошуа Беллу и Рэймонду Той за рецензирование черновика этой статьи и содержательные отзывы.