Внедрение API веб-аудио в первый раз
Это мой второй проект CS Weekly. Прочтите Code Something Weekly: как и почему для вдохновения и идей, лежащих в основе усилий. Проект прошлой недели — анимированный SVG ручной работы, найденный здесь.
На этой неделе я впервые погрузился в Web Audio API. Я построил волновой визуализатор с модуляцией высоты тона и регулировкой громкости. Демонстрация проекта доступна здесь и код находится на Github.
Также есть codepen программы, чтобы возиться с некоторыми значениями.
Тема цифровой обработки звука содержит множество деталей, относящихся к предметной области. Предполагается базовое знакомство с основами, поскольку целью этой статьи является демонстрация использования API, управляющего этими понятиями. Документация MDN содержит краткий обзор концепций аудио, который должен послужить отличной отправной точкой для тех, кто не знаком с этой темой.
API веб-аудио
Описание, данное MDN API веб-аудио, выглядит следующим образом:
Web Audio API включает в себя обработку аудио операций внутри аудиоконтента и был разработан для обеспечения модульной маршрутизации. Основные операции со звуком выполняются с помощью аудиоузлов, которые связаны друг с другом, образуя граф маршрутизации звука. Несколько источников — с разными типами расположения каналов — поддерживаются даже в одном контексте. Эта модульная конструкция обеспечивает гибкость для создания сложных аудиофункций с динамическими эффектами.
Я уверен, что это должно быть описательным и полезным, но при первом прочтении я не мог не просмотреть это несколько раз, не имея реального представления о том, что на самом деле происходит.
Аудиоконтекст?
Модульная маршрутизация?
Аудиоузлы?
В спецификации (учтите, что это только рабочий проект) указано:
Целью этой спецификации является включение возможностей современных игровых звуковых движков, а также некоторых задач микширования, обработки и фильтрации, которые можно найти в современных настольных приложениях для производства звука.
Обработка звука такого рода требует дорогостоящих операций и оптимизаций. Сценарии, которые вы пишете, не будут выполнять большую часть обработки. Скорее вы используете API веб-аудио в качестве высокоуровневого элемента управления для базового оптимизированного механизма.
Аудиоконтекст
Аудиоконтекст — это точка входа в API. Это направленный граф с набором узлов, которые вы логически "соединяете". Его направление показывает, как звук проходит от источника звука через различные узлы к месту назначения.
В целом набор узлов описывает, как синтезировать, фильтровать и в конечном итоге микшировать аудио. Обратите внимание, что это отличается от <audio>
, который используется для встраивания звука, а не для его синтеза и микширования.
В отличие от <canvas>
граф аудиоконтекста не определен в DOM, а существует исключительно в сценариях.
Ниже приведена концептуальная диаграмма того, что представляет аудиоконтекст: граф связанных узлов с направлением.
Псевдокод, соответствующий этой парадигме, выглядит следующим образом:
const audioContext =new AudioContext()
const source1 = audioContext.createSource() const source2 = audioContext.createSource()
const gain1 = audioContext.createGain() const gain2 = audioContext.createGain()
source1.connect(gain1) source2.connect(gain2)
gain1.connect(audioContext.destination) gain2.connect(audioContext.destination)
Это псевдокод, потому что такого метода createSource
не существует. Скорее, существуют определенные типы источников, такие как:
createBufferSource()
— буфер декодированных аудиоданныхcreateConstantSource()
— непрерывно выводить одноканальный сигнал одинакового значенияcreateMediaStreamSource()
— для данного медиапотока создать узел для управления звуком
Модульная маршрутизация
Различные типы узлов реализуют интерфейс AudioNode. Интерфейс описывает узлы как имеющие входы, выходы и два метода, описывающих, как «направить» выход от одного узла к входам другого:
connect(destination, outputIndex, inputIndex)
disconnect(destination, ouput, input)
Примечание.Язык здесь очень точен: маршрутизация выходных от одного узла к входным данным другого.
а.подключить(б)
читается как соединение выхода a со входом b.
Учитывая этот интерфейс, модульная маршрутизация — это возможность просто «соединять» узлы вместе, не беспокоясь о низкоуровневом форматировании. Как указано в спецификации:
…нужные вещи просто случаются.
Во внутренностях интерфейса спрятан способ справиться с различными несовпадающими подключениями. Например, при подключении моно к стерео или наоборот, микширование вверх/вниз происходит у нас автоматически.
Аудио узлы
Мы видели, что существуют различные типы узлов, реализующих интерфейс AudioNode, которые могут подключаться к графу аудиоконтекста. Полный список API см. в документации MDN.
Узлы можно отнести к одному из четырех различных типов:
- Исходные узлы — осцилляторы, медиапоток, буфер.
- Узлы эффектов/модификаций — фильтры, усиление, конвертеры и т. д.
- Узлы анализа — обеспечивают частотный и временной анализ
- Узлы назначения
В визуализаторе волн я использую следующее:
- Oscillator Node x3 (источник) — для создания синусоидальных звуков.
- Gain Node x3 (модификация) — для изменения объемов
- Analyzer Node x3 (анализ) — для получения данных для визуализации
- Динамики устройства (назначение)
После всего сказанного давайте рассмотрим один из способов использования API веб-аудио: визуализатор сигналов.
Идея/Дизайн
Идея довольно прямолинейна. Создайте «аккорд» из 3 частот, каждая из которых имеет свой частотно-высотный модулятор. Постройте данные. Иметь общий регулятор громкости.
Я думаю, что лучше всего разбить это на диаграмме:
Эта конструкция привела меня к следующему разложению:
AudioPlayer
Class — все методы, которые касаются API веб-аудио.- Программа
main
— привязывает DOM кAudioPlayer
, а также определяет метод рисования сигналов. - Вспомогательный класс —
ToggleButton
— крошечный помощник для определения состояний включения/выключения кнопки воспроизведения. Здесь это не рассматривается, но доступно в исходниках на Github.
Я бы сказал, что эта декомпозиция/усилия в целом раздвигают границы того, что считается проектом C.S. Weekly Кролик (см. Code Something Weekly). Все, что выходит за рамки этого, выходит за рамки.
Класс AudioPlayer
Нам нужно сделать несколько вещей:
- Настройка и подключение каждого узла
- Обработка изменений частоты
- Обработка изменений громкости (усиление)
- Предоставьте информацию об анализе
Я разбил это на следующий API:
- общественность
constructor(options)
→AudioPlayer
- частное
_startOscillators()
→void
- частный
_stopOscillators()
→void
- общественность
changeGain(volume)
→void
- общественность
changeFrequency(f, df)
→void
- общественность
getAnalyzerFFTSize(f)
→unsigned long
- общественность
getAnalyzerTimeBytes(f, dataArray)
→void
- общественность
togglePlay()
→void
Конструктор (opts)
Объект opts принимает следующее:
ctx
(Аудиоконтекст)f1
— частота волны 1 (по умолчанию «C»)f2
— частота волны 2 (по умолчанию «E»)f3
— частота волны 3 (по умолчанию «G»)volume
— начальный стартовый объем
Конструктор почти заботится обо всех настройках и соединениях. Но в интерфейсе OscillatorNode
есть неприятный недостаток: у них нет понятия «приостановленного» состояния. Таким образом, об их создании заботится метод _startOscillators()
, который должен выполняться каждый раз, когда мы переключаем воспроизведение.
Давайте начнем с контекста и создания усиления и анализаторов. В документах предлагается создавать узлы через контекст. Другими словами, не делайте что-то вроде: new GainNode()
и вместо этого используйте context.createGain()
.
Давайте посмотрим на это на практике с усилением и анализаторами:
this.ctx = opts.ctx this.gainNode1 = this.ctx.createGain() this.gainNode2 = this.ctx.createGain() this.gainNode3 = this.ctx.createGain() this.f1Analyzer = this.ctx.createAnalyser() this.f2Analyzer = this.ctx.createAnalyser() this.f3Analyzer = this.ctx.createAnalyser()
Довольно просто.
Затем я настраиваю каждую переменную-член, с которой будут работать методы:
this.mF1 = opts.f1 || 130.81 // C this.mF2 = opts.f2 || 164.81 // E this.mF3 = opts.f3 || 196.00 // G this.mPlaying = false this.mVolume = opts.volume || 0.5 this.mVolume = 0.33 * this.mVolume // adjust for 3 gains
Последние две строки можно записать как одну строку, но мне нравится программировать с учетом простоты. Я думаю, что это читается прямо: возьмите объем в диапазоне от 0,0 до 1,0 и разделите его на 3, чтобы учесть суммирование трех приростов.
Наконец, мы устанавливаем усиление и подключаем их к месту назначения:
this.gainNode1.gain.value = this.mVolume this.gainNode2.gain.value = this.mVolume this.gainNode3.gain.value = this.mVolume this.gainNode1.connect(this.ctx.destination) this.gainNode2.connect(this.ctx.destination) this.gainNode3.connect(this.ctx.destination) return this
Мы уже видим, что API веб-аудио делает для нас много тяжелой работы. В несколько простых строк мы настроили 3 анализатора формы сигнала, 3 усиления, отрегулировали усиление и подключили усиление к динамику устройства.
_startOscillators()и _stopOscillators()
Как упоминалось ранее, узлы-осцилляторы не имеют концепции состояния паузы. Таким образом, этот метод существует исключительно для динамического воссоздания и подключения узлов генератора. Предпосылка довольно проста: создайте 3 осциллятора, назначьте им тип волны, частоту и соедините их. Наконец позвоните start()
.
Я продемонстрирую только 1 из узлов, так как то же самое делается для всех 3.
this.oscNode1 = this.ctx.createOscillator() this.oscNode1.type = 'sine' this.oscNode1.frequency.value = this.mF1 this.oscNode1.connect(this.gainNode1) this.oscNode1.connect(this.f1Analyzer) this.oscNode1.start() this.mPlaying = true
Я выбрал sine
для простоты, но примечательно, что API дает нам несколько разных типов: “sine”
, “square”
, “sawtooth”
, “triangle”
, и вы даже можете создавать собственные типы. В случае пользовательской волны вам нужно будет установить период волны и значения, используя метод OscillatorNode.setPeriodicWave(wave)
.
На данный момент я заезженный рекорд, но имейте в виду, что метод connect
соединяет выходы с входами. Таким образом, выход генератора соединен с входом узла анализа и входом узла усиления.
_stopOscillators()
— очень тривиальный метод. Он просто вызывает stop()
на каждом из генераторов и устанавливает this.mPlaying = false
changeGain(volume)
Этот метод является прямым и очень точно отражает то, как мы устанавливаем громкость в конструкторе:
this.mVolume = 0.33 * volume this.gainNode1.gain.value = this.mVolume this.gainNode2.gain.value = this.mVolume this.gainNode3.gain.value = this.mVolume
changeFrequency(f, df)f
содержит число между (1, 3), указывающее, какой из трех осцилляторов следует изменить. dF
— это сумма, на которую нужно изменить:
switch (f) { case 1: this.oscNode1.frequency.value = (this.mF1 + df) ... }
Повторите то же самое для случаев 2
, 3
. Я решил не обновлять состояния mF1
, mF2
, mF3
просто из-за того, как легко изменить частоту с помощью dF
в одну строку. Если вы хотите понизить высоту тона, вызовите dF
в качестве отрицательного значения. Аналогичным образом, чтобы увеличить высоту тона, используйте положительное значение.
getAnalyzerFFTSize(f)
Узлы анализатора берут входные аудиоданные и отправляют их для анализа БПФ. Хотя попытка объяснить БПФ немного выходит за рамки этой статьи, я думаю, что следующее будет полезно для тех, кто не знаком.
Данный звуковой сигнал может быть представлен как сумма более простых периодических волн (т.е. синусов и косинусов). БПФ используется для определения частоты этих волн. Метафора — это то же самое, что бросить бутерброд в машину и получить чистые цельные ингредиенты: рассол, горчицу, веганский сыр и так далее.
В алгоритме БПФ исходный сигнал дискретизируется в течение окна времени. Именно это окно и возвращается: «fftSize
».
switch (f) { case 1: return this.f1Analyzer.fftSize ... }
Причина, по которой это полезно, теперь должна быть ясна: сколько точек данных у нас будет для визуализации волны, связано с fftSize
.
getAnalyzerTimeBytes(f, dataArray)
У узла анализатора есть метод getByteTimeDomainData(dataArray)
. Переданный массив получает копию данных аудиосигнала. Наша функция просто оборачивает это, но принимает параметр f
для осциллятора, для которого мы получаем данные:
switch (f) { case 1: this.f1Analyzer.getByteTimeDomainData(dataArray) case 2: this.f2Analyzer... case 3: this.f3Analyzer... }
Как мы увидим в основной программе, переданный в dataArray
создается с длиной, равной fftSize
, возвращаемому в getAnalyzerFFTSize(f)
.
togglePlay()
Чертовски простой метод для вызова _startOscillators()
или _stopOscillators()
в зависимости от текущего состояния mPlaying
:
this.mPlaying ? this._stopOscillators() : this._startOscillators()
Фу, это было довольно много. Давайте вспомним, как именно мы используем Web Audio API:
- Используйте Audio Context для создания узлов усиления, генератора и анализатора.
- Используйте метод
connect
каждого узла, чтобы сформировать направленный граф от источника к месту назначения. - Установите/измените усиление с помощью
gainNode.gain.value
- Узлы осциллятора имеют
type
иfrequency
. Их звук начинается сstart()
и заканчивается наstop()
- Установите/измените частоты узла генератора, используя
oscillatorNode.frequency.value
- Узлы анализатора дают нам
fftSize
, значение, относящееся к выборке БПФ, сanalyzerNode.fftSize
- Мы можем получить данные во временной области из анализатора, используя
analyzerNode.getByteTimeDomainData(dataArray)
Пользовательский интерфейс
Обсуждение HTML и CSS, используемых в пользовательском интерфейсе, выходит за рамки этой статьи. Я просто продемонстрирую для простоты скелет разметки и кратко обсужу стиль ввода диапазона громкости.
Скелет HTML выглядит следующим образом:
<svg id="playButton"/> <canvas id="visualizer"/> <input data-f="1" id="wave1Control"/> <input data-f="2" id="wave2Control"/> <input data-f="3" id="wave3Control"/> <input id="volumeControl"/>
Для входных данных управления волной я использовал атрибут данных для установки значений от 1 до 3, поскольку класс AudioPlayer ожидает значения от 1 до 3 для параметра f
в нескольких своих методах. Как вы увидите, это упрощает вызов этих методов с правильным параметром f
.
Все <input>
относятся к типу range
. Я установил громкость от (0,0, 1,0) и элементы управления волнами от (-100,0, +100,0).
Вся разметка HTML находится в index.html на Github.
Стилизация слайдеров
Работа с вводом типа range
нетривиальна. В игре есть два основных стиля: трек и большой палец. К сожалению, webkit (chrome) и mozilla реализуют это по-разному, поэтому для стилизации всего в разных браузерах требуется довольно много строк.
Вот пример изменения цвета большого пальца в Chrome и Firefox:
input[type=range]#wave1Control::-webkit-slider-thumb { background-color: #26a69a; } input[type=range]#wave1Control::-moz-range-thumb { background-color: #26a69a; }
Аналогичным образом, для стилизации трека используйте:
input[type=range]#volumeControl::-webkit-slider-runnable-track {...}
и
input[type=range]#volumeControl::-moz-range-track {...}
.
Я хотел, чтобы ползунок громкости отличался от ползунков частоты, чтобы избежать ненужных текстовых меток и сохранить интуитивно понятный интерфейс. Я использовал эти селекторы для достижения этой цели. Весь стиль приложения находится в style.css на Github.
Собираем вещи вместе в Main
После создания экземпляра AudioPlayer и получения ссылок на элементы в DOM мы можем использовать API AudioPlayer в обработчиках событий.
playBtnEl.addEventListener('click', function () { ... // update styles and values in DOM player.togglePlay() }
Мы можем использовать довольно простой способ одновременного добавления прослушивателей событий к трем элементам управления волны:
[ waveCtrl1El, waveCtrl2El, waveCtrl3El ].forEach(ctrl => { ctrl.addEventListener('input', function (e) { if (playBtnStates.current === 'on') { player.changeFrequency( Number(this.dataset.f), Number(this.value) ) } }) })
Мы звоним changeFrequency
только во время воспроизведения звука. Использование атрибутов данных HTML (полученных с помощью this.dataset.f
) упрощает вызов changeFrequency
с правильным параметром. Однако следует отметить, что мы получаем как атрибуты данных, так и значение в виде строк, поэтому сначала преобразуем их в Number
s.
Наконец, давайте посмотрим на громкость:
volumeControl.addEventListener('input', function (e) { if (playBtnStates.current === 'on') { player.changeGain(Number(this.value)) } })
очень просто.
Нанесение данных анализатора на холст
Наконец, давайте посмотрим, как мы на самом деле рисуем данные на холсте.
Данные, с которыми мы работаем:
// Uint8Arrays const f1VisualData const f2VisualData const f3VisualData // reference to <canvas> const visualizer const vCtx = visualizer.getContext('2d')
Во-первых, здесь стоит упомянуть, что мы будем обновлять значения очень часто. Таким образом, немедленно настройте функцию рисования с анимационным кадром:
function draw () { window.requestAnimationFrame(draw) // everything else we write will go here }
Мы собираемся рисовать волны, проводя линию от каждой точки к следующей. Для этого нам нужно знать высоту и ширину холста. Вы также можете добавить любой стиль здесь.
let WIDTH = visualizer.width let HEIGHT = visualizer.height vCtx.fillStyle = ... // set styles, etc.
В каждом кадре анимации нам нужно будет получить обновленные значения аудиосигнала:
player.getAnalyzerTimeBytes(1, f1visualData) player.getAnalyzerTimeBytes(2, f2visualData) player.getAnalyzerTimeBytes(3, f3visualData)
Наконец, нам нужно перебрать каждый из трех массивов и обвести путь в контексте. Поскольку мы будем делать это для каждого из трех массивов, давайте создадим функцию, которую мы сможем вызывать.
drawWave (bufferLength, color, dataArray) { vCtx.beginPath() // todo vCtx.stroke() }
bufferLength
— это длина dataArray
, color
— это цвет, которым нужно рисовать волну, а dataArray
— это массив с данными во временной области.
Во-первых, давайте установим strokeStyle
в параметр color
:
vCtx.strokeStyle = color
Теперь нам нужно выяснить, как нарисовать каждую точку в параметре dataArray
. Поскольку мы рисуем линию из каждой точки, мы должны разделить общую ширину на количество точек, которые у нас есть. Это даст нам длину каждой линии, правильно распределенной по холсту:
let sliceWidth = WIDTH * .75 / bufferLength
Я решил не рисовать волну по всей ширине, поэтому я умножил на 0,75, оставив оставшиеся 25% холста в качестве своего рода «якоря».
Затем мы должны перебрать dataArray
и использовать sliceWidth
для перемещения координаты x.
let x = 0 dataArray.forEach(soundVal => { let y = (dataArray[soundVal] / 256.0)* HEIGHT soundVal === 0 ? vCtx.moveTo(x, y) : vCtx.lineTo(x, y) x += sliceWidth })
Что происходит с y = ...
? Итак, Uint8
— это целое число от 0 до 255. Поэтому, разделив на 256.0
, мы получим значение в диапазоне (0, 1]. Следовательно, координата нашей волны y будет отрисована от 0 до 99% высоты холста.
soundVal === 0
просто проверяет, находимся ли мы в первом индексе. Если это так, перейдите к (0, y) вместо того, чтобы рисовать линию из того места, где мы сейчас находимся в контексте.
Обратите внимание, как x увеличивается на sliceWidth
.
На этом функция drawWave
заканчивается! Вот draw()
целиком:
Вывод
Это была длинная запись. По общему признанию, я думаю, что проект и его описание раздвигают границы духа C.S Weekly. Но учитывая, что это моя вторая итерация, я все еще учусь управлять объемом таких проектов.
В целом API веб-аудио на первый взгляд большой, но его можно довольно просто использовать при наличии базовых знаний в области обработки звука. Это дает программистам более тонкий низкоуровневый контроль над звуком: звук можно синтезировать и модифицировать в реальном времени.
Основная работа выполняется с аудиоконтекстом: ориентированным графом узлов. Каждый узел является источником, анализатором, модификатором или пунктом назначения. Программист определяет поток от одного узла к другому с помощью метода connect
. Анализаторы производят данные в режиме реального времени как во временной, так и в частотной области.
Наконец, есть codepen, если вы хотите повозиться с некоторыми значениями в программе.