Обработка фонового аудио с помощью AudioWorklet
В этой статье объясняется, как создать процессор аудио-ворклет и использовать его в веб-приложении Web Audio.
Когда API Web Audio впервые был представлен в браузерах, он включал возможность использования кода JavaScript для создания пользовательских аудиопроцессоров, которые вызывались для выполнения аудиоманипуляций в реальном времени. Недостатком ScriptProcessorNode было то, что он работал в основном потоке, тем самым блокируя все остальное, пока не завершится выполнение. Это было далеко не идеально, особенно для задач, которые могут быть столь же вычислительно затратными, как обработка звука.
Появился AudioWorklet. Аудио-ворклет контекста аудио является Worklet, который выполняется вне основного потока, выполняя код обработки звука, добавленный в него вызовом метода контекста audioWorklet.addModule(). Вызов addModule() загружает указанный JavaScript-файл, который должен содержать реализацию аудиопроцессора. После регистрации процессора можно создать новый AudioWorkletNode, который пропускает аудио через код процессора, когда узел подключается к цепочке аудиоузлов вместе с другими аудиоузлами.
Стоит отметить, что поскольку обработка звука часто может включать значительные вычисления, ваш процессор может значительно выиграть от построения с использованием WebAssembly, который обеспечивает производительность, близкую к нативной или полностью нативной, для веб-приложений. Реализация вашего алгоритма обработки звука с помощью WebAssembly может значительно улучшить его производительность.
Обзор высокого уровня
Прежде чем начать рассматривать использование AudioWorklet пошагово, давайте начнем с краткого обзора высокого уровня того, что вовлечено.
- Создать модуль, определяющий класс процессора аудио-ворклет, основанный на
AudioWorkletProcessor, который принимает аудио из одного или нескольких входных источников, выполняет свою операцию над данными и выводит результирующие аудиоданные. - Получить доступ к
AudioWorkletконтекста аудио через его свойствоaudioWorkletи вызвать метод аудио-ворклетаaddModule()для установки модуля процессора аудио-ворклета. - По мере необходимости создавать узлы обработки звука, передавая имя процессора (которое определено модулем) конструктору
AudioWorkletNode(). - Настроить любые параметры аудио, которые требуются
AudioWorkletNode, или которые вы хотите настроить. Они определены в модуле процессора аудио-ворклета. - Подключить созданные
AudioWorkletNodeк вашей аудиообрабатывающей цепочке, как любой другой узел, а затем использовать вашу аудиоцепочку как обычно.
В оставшейся части этой статьи мы рассмотрим эти шаги более подробно, с примерами (включая рабочие примеры, которые вы можете попробовать самостоятельно).
Пример кода на этой странице взят из этого рабочего примера, который является частью репозитория MDN GitHub по примерам Web Audio. Пример создает узел осциллятора и добавляет к нему белый шум с помощью AudioWorkletNode, прежде чем воспроизвести полученный звук. Доступны ползунки для управления усилением и осциллятора, и выходного сигнала аудио-ворклета.
Создание процессора аудио-ворклет
В основе процессора аудио-ворклет (который мы будем обычно называть либо "аудиопроцессором", либо "процессором", потому что в противном случае эта статья будет в два раза длиннее) реализуется с помощью JavaScript-модуля, который определяет и устанавливает пользовательский класс аудиопроцессора.
Структура процессора аудио-ворклет
Процессор аудио-ворклет представляет собой JavaScript-модуль, который состоит из следующего:
- Класс JavaScript, который определяет аудиопроцессор. Этот класс расширяет класс
AudioWorkletProcessor. - Класс аудиопроцессора должен реализовать метод
process(), который получает входные аудиоданные и записывает обратно данные, изменённые процессором. - Модуль устанавливает новый класс процессора аудио-ворклет, вызывая
registerProcessor(), указывая имя аудиопроцессора и класс, определяющий процессор.
Один модуль процессора аудио-ворклет может определить несколько классов процессоров, регистрируя каждый из них отдельными вызовами registerProcessor(). Поскольку у каждого есть уникальное имя, это будет работать без проблем. Это также более эффективно, чем загрузка нескольких модулей из сети или даже локального диска пользователя.
Базовая структура кода
Самая простая структура класса аудиопроцессора выглядит так:
class MyAudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputList, outputList, parameters) {
// Using the inputs (or not, as needed),
// write the output into each of the outputs
// …
return true;
}
}
registerProcessor("my-audio-processor", MyAudioProcessor);
После реализации процессора следует вызов глобальной функции registerProcessor(), которая доступна только в рамках AudioWorklet контекста аудио, который вызывается скриптом процессора в результате вызова audioWorklet.addModule(). Этот вызов registerProcessor() регистрирует ваш класс в качестве основы для любых AudioWorkletProcessor, созданных при настройке AudioWorkletNode.
Это самая базовая структура, и она фактически не имеет эффекта, пока в process() не будет добавлен код для работы с этими входными и выходными данными. Что приводит нас к обсуждению этих входов и выходов.
Списки входов и выходов
Списки входов и выходов могут быть немного запутанными вначале, хотя они на самом деле очень просты, когда вы понимаете, что происходит.
Начнем с внутренностей и двигаемся наружу. В основе своей аудио для одного канала аудио (например, левого динамика или сабвуфера) представлено как Float32Array, значения которого являются отдельными аудиообразцами. По спецификации, каждый блок аудио, который получает ваша функция process(), содержит 128 кадров (то есть 128 образцов для каждого канала), но планируется, что это значение изменится в будущем и может фактически варьироваться в зависимости от обстоятельств, поэтому вы должны всегда проверять свойство length массива, а не предполагать определённый размер. Однако гарантируется, что у входов и выходов будет одинаковая длина блока.
Каждый вход может иметь несколько каналов. Моно вход имеет один канал; стерео вход имеет два канала. Звук окружающего пространства может иметь шесть или более каналов. Поэтому каждый вход, в свою очередь, является массивом каналов. То есть массив объектов Float32Array.
Затем может быть несколько входов, поэтому inputList является массивом массивов объектов Float32Array. Каждый вход может иметь разное количество каналов, и каждый канал имеет свой собственный массив образцов.
Таким образом, учитывая список входов inputList:
const numberOfInputs = inputList.length; const firstInput = inputList[0]; const firstInputChannelCount = firstInput.length; const firstInputFirstChannel = firstInput[0]; // (or inputList[0][0]) const firstChannelByteCount = firstInputFirstChannel.length; const firstByteOfFirstChannel = firstInputFirstChannel[0]; // (or inputList[0][0][0])
Список выходов структурирован точно так же; это массив выходов, каждый из которых является массивом каналов, каждый из которых представляет собой объект Float32Array, содержащий образцы для этого канала.
Как вы используете входы и как генерируете выходы, в значительной степени зависит от вашего процессора. Если ваш процессор — просто генератор, он может игнорировать входы и просто заполнять выходы сгенерированными данными. Или вы можете обрабатывать каждый вход независимо, применяя алгоритм к поступающим данным по каждому каналу каждого входа и записывая результаты в соответствующие каналы выходов (помните, что количество входов и выходов может отличаться, и количество каналов на этих входах и выходах также может отличаться). Или вы можете объединить все входы и выполнить смешивание или другие вычисления, которые приводят к одному выходу, заполненному данными (или к тому, что все выходы будут заполнены одинаковыми данными).
Это полностью зависит от вас. Это очень мощный инструмент в вашем инструментарии программирования аудио.
Обработка нескольких входов
Давайте рассмотрим реализацию process(), которая может обрабатывать несколько входов, при этом каждый вход используется для генерации соответствующего выхода. Любые дополнительные входы игнорируются.
process(inputList, outputList, parameters) {
const sourceLimit = Math.min(inputList.length, outputList.length);
for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {
const input = inputList[inputNum];
const output = outputList[inputNum];
const channelCount = Math.min(input.length, output.length);
for (let channelNum = 0; channelNum < channelCount; channelNum++) {
input[channelNum].forEach((sample, i) => {
// Manipulate the sample
output[channelNum][i] = sample;
});
}
};
return true;
}
Обратите внимание, что при определении количества источников для обработки и отправки соответствующим выходам мы используем Math.min(), чтобы убедиться, что мы обрабатываем только столько каналов, сколько у нас есть места в списке выходов. Такая же проверка выполняется при определении количества каналов для обработки в текущем входе; мы обрабатываем только столько, сколько места в выходном назначении. Это предотвращает ошибки из-за превышения этих массивов.
Смешивание входов
Многие узлы выполняют операции смешивания, где входы объединяются каким-то образом в один выход. Это показано в следующем примере.
process(inputList, outputList, parameters) {
const sourceLimit = Math.min(inputList.length, outputList.length);
for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {
let input = inputList[inputNum];
let output = outputList[0];
let channelCount = Math.min(input.length, output.length);
for (let channelNum = 0; channelNum < channelCount; channelNum++) {
for (let i = 0; i < input[channelNum].length; i++) {
let sample = output[channelNum][i] + input[channelNum][i];
if (sample > 1.0) {
sample = 1.0;
} else if (sample < -1.0) {
sample = -1.0;
}
output[channelNum][i] = sample;
}
}
};
return true;
}
Этот код во многом похож на предыдущий пример, но изменяется только первый выход — outputList[0]. Каждый образец добавляется к соответствующему образцу в буфере вывода, с фрагментом кода, предотвращающим выход образцов за допустимый диапазон от -1.0 до 1.0 путем ограничения значений; существуют и другие способы избежать обрезки, которые, возможно, менее подвержены искажению, но это простой пример, который лучше, чем ничего.
Жизненный цикл процессора аудио-ворклет
Единственный способ повлиять на срок службы вашего процессора аудио-ворклета — это значение, возвращаемое process(), которое должно быть булевым значением, указывающим, следует ли переопределять принятие решения пользователем браузера о том, используется ли ваш узел.
В общем случае политика жизненного цикла любого аудиоузла проста: если узел по-прежнему обрабатывает аудио, он будет продолжать использоваться. В случае с AudioWorkletNode, узел считается активным, если его функция process() возвращает true и узел либо генерирует контент в качестве источника аудиоданных, либо получает данные от одного или нескольких входов.
Указание значения true в качестве результата от вашей функции process() по сути сообщает API Web Audio, что ваш процессор должен продолжать вызываться даже если API не считает, что у вас есть работа. Другими словами, true переопределяет логику API и предоставляет вам контроль над политикой жизненного цикла процессора, поддерживая работающий AudioWorkletNode процессора даже тогда, когда он в противном случае решил бы отключить узел.
Возврат значения false из метода process() сообщает API, что он должен следовать своей обычной логике и отключить ваш узел процессора, если это сочтет целесообразным. Если API определит, что ваш узел больше не нужен, process() больше не будет вызываться.
Примечание: К сожалению, в настоящее время Chrome не реализует этот алгоритм в соответствии с текущим стандартом. Вместо этого он сохраняет узел активным, если вы возвращаете true, и отключает его, если возвращаете false. Таким образом, по соображениям совместимости вы всегда должны возвращать true из process(), по крайней мере, в Chrome. Однако после исправления этой проблемы Chrome (ссылка на ошибку) вы захотите изменить это поведение, если это возможно, поскольку это может немного негативно повлиять на производительность.
Создание узла аудиопроцессора worklet
Чтобы создать аудиоузел, который перекачивает блоки аудиоданных через AudioWorkletProcessor, нужно выполнить следующие простые шаги:
- Загрузить и установить модуль аудиопроцессора
- Создать
AudioWorkletNode, указав используемый модуль аудиопроцессора по его имени - Подключить входы к
AudioWorkletNodeи его выходы к соответствующим целям (другие узлы или к свойствуAudioContextdestination).
Для использования процессора аудио-ворклета вы можете использовать код, похожий на следующий:
let audioContext = null;
async function createMyAudioProcessor() {
if (!audioContext) {
try {
audioContext = new AudioContext();
await audioContext.resume();
await audioContext.audioWorklet.addModule("module-url/module.js");
} catch (e) {
return null;
}
}
return new AudioWorkletNode(audioContext, "processor-name");
}
Эта функция createMyAudioProcessor() создаёт и возвращает новый экземпляр AudioWorkletNode, настроенного для использования вашего аудиопроцессора. Она также обрабатывает создание контекста аудио, если он ещё не создан.
Для обеспечения возможности использования контекста, это начинается с создания контекста, если он ещё не доступен, затем добавляет модуль, содержащий процессор, в worklet. После этого создает и возвращает новый AudioWorkletNode. После этого вы подключаете его к другим узлам и используете его как любой другой узел.
Затем вы можете создать новый узел аудиопроцессора, сделав следующее:
let newProcessorNode = await createMyAudioProcessor();
Если возвращаемое значение, newProcessorNode, не null, у нас есть допустимый контекст аудио с его узлом процессора hiss, установленным и готовым к использованию.
Поддержка аудиопараметров
Как и любой другой узел Web Audio, AudioWorkletNode поддерживает параметры, которые совместно используются с AudioWorkletProcessor, выполняющим фактическую работу.
Добавление поддержки параметров к процессору
Чтобы добавить параметры к AudioWorkletNode, нужно определить их внутри класса процессора на основе AudioWorkletProcessor в вашем модуле. Это делается путём добавления статического геттера parameterDescriptors в ваш класс. Эта функция должна возвращать массив объектов AudioParam, по одному для каждого поддерживаемого процессором параметра.
В следующем примере реализации parameterDescriptors(), возвращаемый массив содержит два объекта AudioParam. Первый определяет gain как значение от 0 до 1 с значением по умолчанию 0,5. Второй параметр называется frequency и имеет значение по умолчанию 440,0, с диапазоном от 27,5 до 4186,009 включительно.
static get parameterDescriptors() {
return [
{
name: "gain",
defaultValue: 0.5,
minValue: 0,
maxValue: 1
},
{
name: "frequency",
defaultValue: 440.0,
minValue: 27.5,
maxValue: 4186.009
}
];
}
Доступ к параметрам узла процессора такой же простой, как поиск их в объекте parameters , переданном в реализацию process(). Внутри объекта parameters содержатся массивы, по одному для каждого параметра, и они имеют те же имена, что и ваши параметры.
- Параметры A-rate
-
Для параметров a-rate — параметров, значения которых автоматически меняются со временем — запись параметра в объекте
parametersпредставляет собой массив объектовAudioParam, по одному для каждой рамки обрабатываемого блока. Эти значения должны быть применены к соответствующим кадрам. - Параметры K-rate
-
Параметры K-rate, с другой стороны, могут изменяться только один раз за блок, поэтому массив параметра содержит только одну запись. Используйте это значение для каждой рамки в блоке.
В приведенном ниже коде показана функция process() , которая обрабатывает параметр gain, который может быть использован как параметр a-rate или k-rate. Наш узел поддерживает только один вход, поэтому он просто берет первый вход из списка, применяет к нему усиление и записывает полученные данные в буфер первого выхода.
process(inputList, outputList, parameters) {
const input = inputList[0];
const output = outputList[0];
const gain = parameters.gain;
for (let channelNum = 0; channelNum < input.length; channelNum++) {
const inputChannel = input[channelNum];
const outputChannel = output[channelNum];
// If gain.length is 1, it's a k-rate parameter, so apply
// the first entry to every frame. Otherwise, apply each
// entry to the corresponding frame.
if (gain.length === 1) {
for (let i = 0; i < inputChannel.length; i++) {
outputChannel[i] = inputChannel[i] * gain[0];
}
} else {
for (let i = 0; i < inputChannel.length; i++) {
outputChannel[i] = inputChannel[i] * gain[i];
}
}
}
return true;
}
Здесь, если gain.length указывает, что в массиве значений параметра gain есть только одно значение, первое значение в массиве применяется к каждой рамке в блоке. В противном случае для каждой рамки в блоке соответствующее значение в gain[] применяется.
Доступ к параметрам из скрипта главной нити
Ваш скрипт главной нити может получить доступ к параметрам, как и к любому другому узлу. Для этого сначала нужно получить ссылку на параметр, вызвав метод AudioWorkletNode свойство parameters метод get():
let gainParam = myAudioWorkletNode.parameters.get("gain");
Возвращаемое и сохранённое в gainParam значение — AudioParam, используемый для хранения параметра gain. Затем вы можете изменить его значение, действующее в заданное время, используя метод AudioParam setValueAtTime().
Например, здесь мы устанавливаем значение в newValue, действующее немедленно.
gainParam.setValueAtTime(newValue, audioContext.currentTime);
Аналогичным образом вы можете использовать любые другие методы интерфейса AudioParam, чтобы применять изменения во времени, отменять запланированные изменения и так далее.
Чтение значения параметра такое же простое, как просмотр его свойства value:
let currentGain = gainParam.value;
См. также
- Web Audio API
- Ввод аудио-ворклетов (блог Chrome Developers)
© 2005–2024 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_AudioWorklet