深入淺出 Web Audio Api

題圖:[Egor Khomiakov](Pioneer music player and amplifier photo by Egor Khomiakov (@mad1999) on Unsplash)

什麼是 Web Audio Api

首先引用一下 MDN 上對 Web Audio Api 的一段描述:

The Web Audio API involves handling audio operations inside an audio context, and has been designed to allow modular routing. Basic audio operations are performed with audio nodes, which are linked together to form an audio routing graph.

大致的意思就是 Web Audio API 需要在音頻上下文中處理音頻的操作,並具有模塊化路由的特點。基本的音頻操作是通過音頻節點來執行的,這些音頻節點被連接在一起形成音頻路由圖。

我們可以從上面這段文字中提取出幾個關鍵詞:

  • 音頻上下文
  • 音頻節點
  • 模塊化
  • 音頻圖

我將會以這些關鍵詞為開始,慢慢介紹什麼是 Web Audio Api,如何使用 Web Audio Api 來處理音頻等等。

音頻上下文(`AudioContext`)

音頻中的 `AudioContext` 可以類比於 `canvas` 中的 `context`,其中包含了一系列用來處理音頻的 `API`,簡而言之,就是可以用來控制音頻的各種行為,比如播放、暫停、音量大小等等等等。創建音頻的 `context` 比創建 `canvas` 的 `context` 簡單多了(考慮代碼的簡潔性,下面代碼都不考慮瀏覽器的兼容情況):

const audioContext = new AudioContext();

在繼續了解 `AudioContext` 之前,我們先來回顧一下,平時我們是如何播放音頻的:

<audio autoplay src="path/to/music.mp3"></audio>

或者:

const audio = new Audio();audio.autoplay = true;audio.src = path/to/music.mp3;

沒錯,非常簡單的幾行代碼就實現了音頻的播放,但是這種方式播放的音頻,只能控制播放、暫停等等一些簡單的操作。但是如果我們想要控制音頻更「高級」的屬性呢,比如聲道的合併與分割、混響、音調、聲相控制和音頻振幅壓縮等等,可以做到嗎?答案當然是肯定的,一切都基於 `AudioContext`。我們以最簡單的栗子來了解一下 `AudioContext` 的用法:

const URL = path/to/music.mp3;const audioContext = new AudioContext();const playAudio = function (buffer) { const source = audioContext.createBufferSource(); source.buffer = buffer; source.connect(audioContext.destination); source.start();};const getBuffer = function (url) { const request = new XMLHttpRequest(); return new Promise((resolve, reject) => {request.open(GET, url, true); request.responseType = arraybuffer; request.onload = () => { audioContext.decodeAudioData(request.response, buffer => buffer ? resolve(buffer) : reject(decoding error)); }; request.onerror = error => reject(error); request.send(); });};const buffer = await getBuffer(URL);buffer && playAudio(buffer);

別方,這個栗子真的是最簡單的栗子了(盡量寫得簡短易懂了),其實仔細看下,代碼無非就做了三件事:

  • 通過 `ajax` 把音頻數據請求下來;
  • 通過 `audioContext.decodeAudioData()` 方法把音頻數據轉換成我們所需要的 `buffer` 格式;
  • 通過 `playAudio()` 方法把音頻播放出來。

你沒猜錯,達到效果和剛剛提到的播放音頻的方式一毛一樣。這裡需要重點講一下 `playAudio` 這個函數,我提取出了三個關鍵點:

  • `source`
  • `connect`
  • `destination`

你可以試著以這種方式來理解這三個關鍵點:首先我們通過 `audioContext.createBufferSource()` 方法創建了一個「容器」 `source` 並裝入接收進來的「水」 `buffer`;其次通過「管道」 `connect` 把它和「出口」 `destination` 連接起來;最終「出口」 `destination` 「流」出來的就是我們所聽到的音頻了。不知道這麼講,大家有沒有比較好理解。

或者也可以拿 webpack 的配置文件來類比:

module.exports = { // source.buffer entry: main.js, // destination output: { filename: app.js, path: /path/to/dist, },};

`source` 和 `destination` 分別相當於配置中的入口文件和輸出文件,而 `connect` 相當於 webpack 內置的默認 `loader`,負責把源代碼 `buffer` 生成到輸出文件中。

重點理解這三個關鍵點的關係。

注意:`Audio` 和 Web Audio 是不一樣的,它們之間的關係大概像這樣:

`Audio`:

  • 簡單的音頻播放器;
  • 「單線程」的音頻;

Web Audio:

  • 音頻合成;
  • 可以做音頻的各種處理;
  • 遊戲或可交互應用中的環繞音效;
  • 可視化音頻等等等等。

音頻節點(`AudioNode`)

到這裡,大家應該大致知道了如何通過 `AudioContext` 去控制音頻的播放。但是會發現寫了這麼一大堆做的事情和前面提到的一行代碼的所做的事情沒什麼區別(`<audio autoplay src="path/to/music.mp3"></audio>`),那麼 `AudioContext` 具體是如何去處理我們前面所提到的那些「高級」的功能呢?就是我們接下來正要了解的 音頻節點

那麼什麼是音頻節點呢?可以把它理解為是通過「管道」 `connect` 連接在「容器」`source` 和「出口」 `destination` 之間一系列的音頻「處理器」。`AudioContext` 提供了許多「處理器」用來處理音頻,比如音量「處理器」 `GainNode`、延時「處理器」 `DelayNode` 或聲道合併「處理器」 `ChannelMergerNode` 等等。

前面所提到的「管道」 `connect` 也是由音頻節點 `AudioNode` 提供的,所以你猜的沒錯,「容器」 `source` 也是一種音頻節點。

const source = audioContext.createBufferSource();console.log(source instanceof AudioNode); // true

`AudioNode` 還提供了一系列的方法和屬性:

  • `.context` (read only): `audioContext` 的引用
  • `.channelCount`: 聲道數
  • `.connect()`: 連接另外一個音頻節點
  • `.start()`: 開始播放
  • `.stop()`: 停止播放

更多詳細介紹可訪問 MDN 文檔。

GainNode

前面有提到音頻處理是通過一個個「處理器」來處理的,那麼在實際應用中怎麼把我們想要的「處理器」裝上去呢?

Dont BB, show me the code:

const source = audioContext.createBufferSource();const gainNode = audioContext.createGain();const buffer = await getBuffer(URL);source.buffer = buffer;source.connect(gainNode);gainNode.connect(source.destination);const updateVolume = volume => gainNode.gain.value = volume;

可以發現和上面提到的 `playAudio` 方法很像,區別只是 `source` 不直接 connect 到 `source.destination`,而是先 connect 到 `gainNode`,然後再通過 `gainNode` connect 到 `source.destination`。這樣其實就把「音量處理器」裝載上去了,此時我們通過更新 `gainNode.gain.value` 的值(`0 - 1` 之間)就可以控制音量的大小了。

Full Demo

BiquadFilterNode(waiting for perfection)

不知道怎麼翻譯這個「處理器」,暫且叫做低階濾波器吧,簡單來說它就是一個通過過濾音頻的數字信號進而達到控制 音調 的音頻節點。把它裝上:

const filterNode = audioContext.createBiquadFilter();// ...source.connect(filterNode);filterNode.connect(source.destination);const updateFrequency = frequency => filterNode.frequency.value = frequency;

這樣一來我們就可以通過 `updateFrequency()` 方法來控制音頻的音調(頻率)了。當然,除了 `frequency` 我們還可以調整的屬性還有(MDN Docs):

  • `.Q`: quality factor;
  • `.type`: lowpass, highpass, bandpass, lowshelf, highshelf, peaking, notch, allpass;
  • `.detune`: detuning of the frequency in cents.

Full Demo

PannerNode

我們可以調用 PannerNode 的 `.setPosition()` 方法來做出非常有意思的 3D 環繞音效:

<input type="range" name="rangeX" value="0" max="10" min="-10">

const rangeX = document.querySelector(input[name="rangeX"]);const source = audioContext.createBufferSource();const pannerNode = audioContext.createPanner();source.connect(pannerNode);pannerNode.connect(source.destination);rangeX.addEventListener(input, () => pannerNode.setPosition(rangeX.value, 0, 0));

還是老方法「裝上」 `PannerNode` 「處理器」,然後通過監聽 `range` 控制項的 `input` 事件,通過 `.setPosition()` 方法更新 聲源相對於聽音者的位置,這裡我只簡單的更新了聲源相對於聽音者的 `X` 方向上的距離,當值為負值時,聲音在左邊,反之則在右邊。

你可以這麼去理解 `PannerNode`,它把你(聽音者)置身於一個四面八方都非常空曠安靜的空間中,其中還有一個音響(聲源),而 `.setPosition()` 方法就是用來控制 音響 在空間中 相對於你(聽音者) 的位置的,所以上面這段代碼可以控制聲源在你左右倆耳邊來回晃動(帶上耳機)。

Full Demo

當然,對於 `PannerNode` 來說,還有許多屬性可以使得 3D 環繞音效聽上去更逼真,比如:

  • `.distanceModel`: 控制音量變化的方式,有 3 種可能的值:`linear`, `inverse` 和 `exponential`;
  • `.maxDistance`: 表示 聲源聽音者 之間的最大距離,超出這個距離後,聽音者將不再能聽到聲音;
  • `.rolloffFactor`: 表示當 聲源 遠離 聽音者 的時候,音量以多快的速率減小;

這裡只列舉了常用的幾個,如果想進一步了解 `PannerNode` 能做什麼的話,可以查閱 MDN 上的 文檔。

多個音頻源

前面有提到過,在 `AudioContext` 中可以同時使用多個「處理器」去處理一個音頻源,那麼多個音頻源 `source` 可以同時輸出嗎?答案當然也是肯定的,在 `AudioContext` 中可以有多個音頻處理通道,它們之間互不影響:

const sourceOne = audioContext.createBufferSource();const sourceTwo = audioContext.createBufferSource();const gainNodeOne = audioContext.createGain();const gainNodeTwo = audioContext.createGain();sourceOne.connect(gainNodeOne);sourceTwo.connect(gainNodeTwo);gainNodeOne.connect(audioContext.destination);gainNodeTwo.connect(audioContext.destination);

Full Demo

模塊化(`Modular`)

通過前面 音頻節點 的介紹,相信你們已經感受到了 Web Audio 的模塊化設計了,它提供了一種非常方便的方式來為音頻裝上(`connect`)不同的「處理器」 `AudioNode`。不僅一個音頻源可以使用多個「處理器」,而多個音頻源也可以合併為一個「輸出」 `destination`。

得益於 Web Audio 的模塊化設計,除了上面提到的模塊(`AudioNode`),它還提供了非常多的可配置的、高階的、開箱即用的模塊。所以通過使用這些模塊,我們完全可以創建出功能豐富的音頻處理應用。

如果你對 `AudioContext` 和 `AudioNode` 之間的關係還沒有一個比較清晰的概念的話,就和前面一開始所說的那樣,把它們和 webpack 和 `loader` 做類比,`AudioContext` 和 webpack 相當於一個「環境」,模塊(`AudioNode` 或 `loader`)可以很方便在「環境」中處理數據源(`AudioContext` 中的 `buffer` 或 webpack 中的 `js`, `css`, `image` 等靜態資源),對比如下:

module.exports = { entry: { // 多音頻源合併為一個輸出 app: [main.js], // source.buffer vender: [vender], // source.buffer }, output: { // source.destination filename: app.js, path: /path/to/dist, }, // AudioNode module: { rules: [{ // source.buffer test: /.(scss|css)$/, // AudioNode: GainNode, BiquadFilterNode, PannerNode ... use: [style-loader, css-loader, sass-loader], }], },};

再次發現,Web Audio Api 和 webpack 的設計理念如此的相似。

音頻圖(`Audio Graph`)

An audio graph is a set of interconnected audio nodes.

現在我們知道了,音頻的處理都是通過 音頻節點 來處理的,而多個音頻節點 `connect` 到一起就形成了 音頻導向圖(Audio Routing Graph),簡而言之就是多個相互連接在一起的音頻節點。

總結

本文展示的僅僅只是 Web Audio 眾多 API 中的冰山一角,如果想更深入了解 Web Audio 的話,建議可以去查閱相關文檔。儘管如此,利用上面介紹的一些 API 也足夠做出一些有意思的音樂效果來了。

參考資料

  1. Web Audio Api - MDN
  2. Getting Started with Web Audio API

推薦閱讀:

Typescript玩轉設計模式 之 對象行為型模式(上)
前端日刊-2018.01.15
前端日刊-2018.01.06
熟練使用這幾款仿站工具, 推廣事半功倍
git和github初使用

TAG:前端開發 | WebAudio |