深入淺出 Web Audio Api

大板慄發表於2017-08-24

題圖:Egor Khomiakov

注:本文同時釋出在 知乎專欄

什麼是 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 比建立 canvascontext 簡單多了(考慮程式碼的簡潔性,下面程式碼都不考慮瀏覽器的相容情況):

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 「流」出來的就是我們所聽到的音訊了。不知道這麼講,大家有沒有比較好理解。

AudioContext
AudioContext

或者也可以拿 webpack 的配置檔案來類比:

module.exports = {
    // source.buffer
    entry: 'main.js',
    // destination
    output: {
        filename: 'app.js',
        path: '/path/to/dist',
    },
};複製程式碼

sourcedestination 分別相當於配置中的入口檔案和輸出檔案,而 connect 相當於 webpack 內建的預設 loader,負責把原始碼 buffer 生成到輸出檔案中。

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

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

Web audio API and Audio
Web audio API and 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

GainNode
GainNode

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

Don't 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)

BiquadFilterNode
BiquadFilterNode

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

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, inverseexponential
  • .maxDistance: 表示 聲源聽音者 之間的最大距離,超出這個距離後,聽音者將不再能聽到聲音;
  • .rolloffFactor: 表示當 聲源 遠離 聽音者 的時候,音量以多快的速率減小;

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

多個音訊源

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

cross fading
cross fading

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)

Modular
Modular

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

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

如果你對 AudioContextAudioNode 之間的關係還沒有一個比較清晰的概念的話,就和前面一開始所說的那樣,把它們和 webpack 和 loader 做類比,AudioContext 和 webpack 相當於一個「環境」,模組(AudioNodeloader)可以很方便在「環境」中處理資料來源(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)

Audio Graph
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

相關文章