遇見你,很幸運——初識 Web Audio

注:本文使用的Web Audio API遵循W3C在18年9月發佈的候選推薦版本,本文代碼在Chrome76中測試通過,請注意代碼兼容性。如有錯誤,請不吝指正。

Web Audio Api的兼容性(數據來自can i use)

引言

每段緣起始都很簡單,或是擦肩而過的那陣清風,或是四目相對的那縷溫情,亦是共經患難的那份情誼……初春的一天,在我被工作中出現的問題折磨的不堪之際,經朋友引薦,她出現在我的視野中。我忘不了她解決問題時的颯爽英姿,那幹練的身影在我腦海中遲遲不能散去。我有意要了解她,卻總感覺只在冰山一角。終於經歷了幾個月的徘徊後,我下定決心不再等待,開一個系列來介紹我的理解。Why now? Why not?

本文是《認識 Web Audio》系列文章的第一篇,主要涉及音頻上下文的一些簡單概念的介紹和使用。整個系列分爲以下文章:

  • 遇見你,很幸運——初識 Web Audio
  • 知其然,知其所以然——Web Audio原理探究
  • 我和音符有個約會——音頻可視化篇
  • 爲你彈奏肖邦的夜曲——音頻創作篇
  • 是誰帶來遠古的呼喚——音頻空間化篇

Web Audio背景

長期以來在網頁上播放音視頻都是一個痛點,雖然HTML5引入了audio、video標籤來實現基本的音視頻播放,但卻不足以應付更復雜情況如遊戲引擎、實時交互等場景下的音頻處理。於是,Web Audio應運而生。但要說明的是,它之於audio標籤並不是替代關係,而是類似img和canvas的一種補充的關係。Web Audio播放音頻能實現安卓、ios的一致性,消除諸如播放有延遲、不能循環播放等問題,如howler.js(當然不僅於此);其次可以利用振盪器、濾波器等創造自己的樂器,進行交互式音樂的構建、實現歌曲的演奏,如Tone.js;還可以對音頻播放進行可視化,如Pts.js……

Web Audio是應在web瀏覽器中處理音頻的需求而產生的,它早已不是一個新鮮的概念,但並沒有大規模使用卻是一個不爭的事實。除去不值一提的兼容性問題,我想原因大概有幾個,首先是API居多,因爲它要實現在瀏覽器產生、處理音頻必須要有相應的接口,但和專業音頻製作程序仍有一定差距,不是特別能吸引專業人士;然後就是要實現實時WebRTC、遊戲級引擎的音頻特效等所需要的專業知識(數字信號處理、通信原理等)所帶來的學習成本也是讓很多人望而卻步。不過大勢所趨,它還在處於不斷發展中,相信未來前景還是不錯的。

Web Audio提供的合成聲音、添加特效、音頻可視化功能,是在音頻上下文完成的,它將不同的操作細化爲對應的節點實現了模塊化(Modular routing),各個節點又可以通過connect方法相連接,進行必要的處理後,最終輸出到目標節點(音頻上下文的destination屬性,通常是聲音輸出設備如揚聲器)。整個流程連接在一起形成一個音頻路由圖(audio routing graph)。

Web Audio Api簡單工作流程爲:

  1. 創建音頻上下文(AudioContext或OfflineAudioContext)
  2. 在上下文中創建聲音來源(如audio標籤、xmlhttprequest請求得到的arraybuffer、oscillator振盪器產生的各種波形)
  3. 創建特效節點(如混響、各種濾波、平移、壓縮)
  4. 選擇音頻的最終產出地(如系統揚聲器)
  5. 將來源連到特效節點,將特效節點連到目的節點

    Web Audio Api使得我們可以更精確(時間上可以做到無延遲、空間上可以產生衰減效果模擬真實環境)的操作音頻,這是audio標籤所不具備的。舉個例子來說,如果要實現音頻的漸入漸出效果,原始方法需要使用定時器來不斷更改volume來實現,而使用web audio只需將源節點連到增益節點(GainNode),設置gainNode.gain.linearRampToValueAtTime(value, endTime)就能實現在endTime-currentTime時間段內,聲音值從原始到value值的變化,可以說操縱很輕鬆了。

Web Audio基礎

在正式使用之前,首先了解一下Web Audio Api的部分接口。

音頻上下文接口
  • BaseAudioContext是實際使用的音頻上下文AudioContext(用於實時渲染)和OfflineAudioContext(用於離線渲染)的基類,不能被直接實例化。音頻節點都在其內創建並相互連接在一起,允許信號最終連接到AudioDestinationNode節點進行播放,形成一個音頻路由圖。其包含的部分屬性如下:
    state屬性是一個枚舉屬性,取值爲 {‘suspended’, ‘running’, ‘closed’},表示音頻上下文的當前狀態。前兩個可通過實例的resume()suspend()方法切換,調用close()之後音頻上下文將會釋放系統資源,不能再次使用。
    currentTime屬性表示上下文的運行時間,當上下文處於running狀態時該值會以均勻的速度單調遞增,由渲染線程控制,不一定和處理的音頻時間同步。
    destination屬性是一個AudioDestinationNode節點,通常是實際的音頻輸出設備。
    sampleRate只讀屬性,表示採樣率,在處理過程中保持不變,因此實時處理中不支持採樣率轉換。在不設置的情況下默認爲音頻輸出設備的採樣率。進行處理時使用的採樣率如果和音頻輸出時的採樣率不一致會進行重採樣。

  • AudioContext實時音頻上下文,來直接爲用戶產生信號。AudioContext初始化時state默認爲suspended,因爲自動播放策略限制,必須經過用戶操作後才允許處於運行狀態,這可通過resume()恢復音頻上下文,或者調用AudioBufferSourceNode的start()時來恢復上下文。主動暫停上下文使用suspend(),關閉使用close()。但要注意的是雖然二者都可以釋放包括線程,進程和音頻流等系統資源。但暫停後可通過resume()恢復運行,關閉AudioContext則意味着釋放所有資源,此後將無法使用或再次恢復它。構造函數中可傳入contextOptions的對象,如果選項中包含sampleRate屬性,則採樣率設置爲該屬性,否則使用默認輸出設備的採樣率。

  • OfflineAudioContext離線音頻上下文,雖然有destination屬性,但實際並不會渲染到音頻輸出硬件中,但會儘可能快的渲染(一般來說要比實時渲染更快),通過startRendering()返回AudioBuffer,適用於那些可以在後臺進行音頻處理的場景。使用OfflineAudioContext(numberOfChannels, length, sampleRate)構造函數進行初始化,參數必填,不過也可以將一個包含這三個屬性的對象(sampleRate/length必須屬性)作爲參數傳遞。因爲構造時就已經確定了長度,所以在渲染length/smapleRate時間之後其狀態就會變爲closed,不能繼續使用了。和AudioContext不同的是,離線音頻上下文只有suspend(suspendTime)方法,沒有close()方法,在音頻數據渲染完之後主動關閉。終止時必須傳入終止時間,以在指定時刻終止,該方法一般來說只有在同步操作音頻數據時纔有用。

音頻節點、數據和音頻控制接口
  • AudioNode:音頻節點,是所有展示在音頻路由圖中節點模塊(如音頻源節點、目的節點、過濾器節點、增益節點等)的基類,不能直接實例化,提供connect方法實現節點間的連接。

  • AudioBuffer:音頻緩衝區,由createBuffer(numberOfChannels, length, sampleRate)或構造器創建而來,參數都是必填,生成指定長度的音頻buffer,該buffer默認初始化爲0。該buffer包含duration(=length/sampleRate)、length、numberOfChannels、sampleRate屬性。可通過getChannelData(channel)方法獲取某一通道下的音頻數據,返回一個Float32Array類型的數據。它只是保存數據源,不是真正的音頻節點,只有賦值給相應的AudioNode纔有作用。像AudioBufferSourceNode的buffer就是AudioBuffer類型。

  • AudioParam:音頻參數接口,控制AudioNode的某個方面,比如音量。該接口除了value屬性,其他屬性都是隻讀的。可以直接爲參數的value賦值,也可以使用AudioParam的方法實現預先設定。setValueAtTime(value, startTime)可以做到的當AudioContext的currentTime走到給定的startTime時間後進行賦值爲value。linearRampToValueAtTime(value, endTime)可以做到從當前時間到endTime從當前值線性變化到value值。常用場景是實現聲音播放的漸隱漸現,來避免使用定時器實現音量大小的設置。setTargetAtTime(target, startTime, timeConstant)可用於實現聲音信號的衰變。

音頻源節點
  • AudioBufferSourceNode:音頻數據源節點,由createBufferSourceNode()方法或構造器創建得到。它接受一個AudioBuffer作爲buffer屬性來提供數據源,數據源必須來自內存。

  • MediaElementAudioSourceNode:媒體元素節點,由指定的HTMLMediaElement(通常爲audio或video標籤)創建得到,調用該方法後,音頻播放的控制權移交給當前音頻上下文的路由圖。即只有當前AudioContext狀態爲running時才能播放,並且要連接到AudioContext的destination節點才能播放出聲音。它允許我們操作audio、video聲音軌道數據。

  • MediaStreamAudioSourceNode:媒體流音頻節點,獲取實時媒體流的音頻源。使用音頻上下文的createMediaStreamSource(mediaStream)方法或new MediaStreanAudioSourceNode(context, {mediaStream})創建而來

音頻目的節點
  • MediaStreamAudioDestinationNode:音頻流輸出目的節點,數據存儲在stream屬性中,做臨時存儲用。

  • AudioDestinationNode:音頻目的節點,每個AudioContext只有唯一的一個該節點,由BaseAudioContext的destination屬性提供,一般爲對應的音頻輸出硬件,如揚聲器。

Web Audio的使用

接下來從加載音頻的角度講述如何使用Web Audio。加載音頻方式主要有四種

  • 使用HTMLMediaElement元素(如audio/video)作爲數據源,適合應用於音頻數據量很大的場景

    下面是一個接管頁面audio標籤音頻播放的示例,並使用gainNode節點來控制音頻的音量

<audio src="./1.mp3" controls></audio>
<button>播放</button>
<input type="range" value="1" min="0" max="3" step=".1" />
<script>
  const ac = new AudioContext()
  const range = document.querySelector('input[type="range"]')
  const btn = document.querySelector('button')
  const audio = document.querySelector('audio')
  const ms = ac.createMediaElementSource(audio)
  // 等價於以下語句
  // const bf = new MediaElementAudioSourceNode(ac, { mediaElement: audio })
  const gainNode = ac.createGain()
  gainNode.gain.value = 1
  ms.connect(gainNode).connect(ac.destination)
  btn.onclick = function() {
    // AudioContext若處於suspended狀態,需要恢復後才能正常播放
    if (ac.state === 'suspended') ac.resume()
    audio.play()
  }
  range.onchange = e => {
    gainNode.gain.value = e.target.value
  }
</script>
  • ajax異步獲取數據:因爲只有當全部加載到音頻數據後才能使用,所以一般應用在少量音頻數據場景中

    下面是一個先使用離線音頻上下文異步獲取到音頻數據再傳給音頻上下文進行播放的例子

const ac = new AudioContext()
// 這裏只是爲展示OfflineAudioContext的使用,實際上完全可以直接用AudioContext異步獲取到數據進行播放
const oc = new OfflineAudioContext(2, 44000 * 100, 44000)
async function fetchAudio(src) {
  const file = await fetch(src)
  const fileBuffer = await file.arrayBuffer()
  const buffer = await oc.decodeAudioData(fileBuffer)
  const bufferSource = oc.createBufferSource()
  bufferSource.buffer = buffer
  bufferSource.connect(oc.destination)
  bufferSource.start(0)
  oc.startRendering()
   .then(buffer => {
     // 這裏對a、b進行賦值是爲了給第三個例子的buffer創造來源
     window.a = buffer.getChannelData(0)
     window.b = buffer.getChannelData(1)
     const bs = new AudioBufferSourceNode(ac, { buffer })
     bs.connect(ac.destination)
     bs.start(0)
   })
   .catch(e => {
     console.log(e)
   })
}
fetchAudio('1.mp3')
  • 自定義聲音:使用OscillatorNode產生類似正弦、鋸齒形波形或者創建buffer並使用數據填充

    下面展示了一個使用自定義數據填充AudioBuffer的例子,在創建buffer時需要傳入信道數、採樣率、採樣長度,使用AudioBuffer的copyToChannel()方法可以實現將一個Float32Arrays類型數據複製到指定信道。當然數據也可以自己產生,mdn官網上有一個使用隨機數生成噪聲的例子

const ac = new AudioContext()
const buffer2 = ac.createBuffer(2, 10 * 41000, 41000)
// 這裏使用了上面第二個例子代碼中的數據來源
buffer2.copyToChannel(a, 0, 0)
buffer2.copyToChannel(b, 1, 0)
const bf = ac.createBufferSource()
bf.buffer = buffer2
bf.connect(ac.destination)
bf.start()
  • 通過Media Stream API獲取相機和麥克風的數據:使用MediaSteamAudioSourceNode來實現,適用於WebRTC或想要錄製音頻的場景

    下面是一個使用getUserMedia和MediaRecorder進行錄音的例子。getUserMedia獲取用戶錄音的權限,因爲在錄音過程中不需要輸出,所以將音頻流保存至MediaStreamAudioDestinationNode節點,並且在音頻流傳遞過程中還使用了低通濾波器實現低音增強,錄完音保存至audio標籤,使用原生控件可進行播放和下載。同樣,此例子只做展示用,其實直接將mediaStream作爲參數傳遞給MediaRecorder構造器也是能實現錄音。不過要注意的是MediaRecorder目前還僅在高版本PC瀏覽器中受支持,不太適合應用於生產環境。

<audio controls></audio>
<button>開始錄音</button>
<script>
  const ac = new AudioContext()
  const biquadFilter = ac.createBiquadFilter()
  const msa = new MediaStreamAudioDestinationNode(ac)
  const mediaRecorder = new MediaRecorder(msa.stream)
  let chunks = [],
    start = false
  const btn = document.querySelector('button')
  const audio = document.querySelector('audio')
  // 麥克風->低音濾波->音頻流目的節點 在這個過程中進行錄製->audio標籤播放
  navigator.mediaDevices
    .getUserMedia({
      audio: true
    })
    .then(mediaStream => {
      const ms = ac.createMediaStreamSource(mediaStream)
      biquadFilter.type = 'lowshelf'
      biquadFilter.frequency.value = 40000
      biquadFilter.gain.value = 1
      ms.connect(biquadFilter).connect(msa)
    })
    .catch(e => console.log(e))
  // 點擊按鈕開始錄音或結束錄音
  btn.onclick = () => {
    if (!start) {
      mediaRecorder.start()
      btn.innerText = '結束錄音'
    } else {
      mediaRecorder.stop()
      btn.innerText = '開始錄音'
    }
    start = !start
  }
  // 把得到的錄音流保存至chunk
  mediaRecorder.ondataavailable = e => {
    chunks.push(e.data)
  }
  mediaRecorder.onstop = e => {
    const blob = new Blob(chunks, { type: 'audio/ogg; codecs=opus' })
    audio.src = URL.createObjectURL(blob)
  }
</script>

參考資料

  1. Web Audio API規範
  2. MDN Web_Audio_API專題
發佈了21 篇原創文章 · 獲贊 27 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章