Внедрение 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

Нам нужно сделать несколько вещей:

  1. Настройка и подключение каждого узла
  2. Обработка изменений частоты
  3. Обработка изменений громкости (усиление)
  4. Предоставьте информацию об анализе

Я разбил это на следующий 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:

  1. Используйте Audio Context для создания узлов усиления, генератора и анализатора.
  2. Используйте метод connect каждого узла, чтобы сформировать направленный граф от источника к месту назначения.
  3. Установите/измените усиление с помощью gainNode.gain.value
  4. Узлы осциллятора имеют type и frequency. Их звук начинается с start() и заканчивается на stop()
  5. Установите/измените частоты узла генератора, используя oscillatorNode.frequency.value
  6. Узлы анализатора дают нам fftSize, значение, относящееся к выборке БПФ, с analyzerNode.fftSize
  7. Мы можем получить данные во временной области из анализатора, используя 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 с правильным параметром. Однако следует отметить, что мы получаем как атрибуты данных, так и значение в виде строк, поэтому сначала преобразуем их в Numbers.

Наконец, давайте посмотрим на громкость:

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, если вы хотите повозиться с некоторыми значениями в программе.