淺談H5音訊處理(更多談談錄音方向的內容)

嘻嘻啊☺️發表於2018-08-19

最近需要做一個實時錄音然後根據音訊流實時反饋出呼叫靜音分析(VAD)以及語音識別(ASR)介面的功能。於是研究起H5有關這方面的支援。

H5的Web Audio API

首先需要弄清一點,Web Audio API和H5的<audio>完全不是一個體量級的東西,<audio>可以很方便地讓你將音訊檔案丟進去就自帶各種花式功能。但是如果直接用Web Auido API進行操作,你甚至可以無中生有地創造聲音。 這裡粗糙地羅列一下它能做什麼事情(盡我所能查到的資料):

  1. 對簡單或複雜的聲音進行混合;
  2. 精確地控制聲音的密度和節奏;
  3. 內建淡入、淡出,顆粒噪點、音調控制等效果;
  4. 靈活的處理在音訊流的聲道,使它們成為拆分和合並;
  5. 處理從<audio>音訊或<video>視訊的媒體元素和音訊源。
  6. 使用MediaStream的getUserMedia()方法實時處理現場輸入的音訊,例如變聲;
  7. 立體音效,支援多種3D遊戲和沉浸式環境。
  8. 利用卷積引擎,創造各種線性音效,例如小/大房間、大教堂、音樂廳、洞穴、隧道、走廊、森林等。尤其適用於建立高質量的房間效果。(就是音樂播放器的那種特效~)
  9. 高效實時的時域和頻的分析,配合CSS3或Canvas或WebGL可以做到音樂的視覺化支援。
  10. 音訊波形演算法控制,也就是隻要你研究地足夠透徹,你把AU搬到web上實現也就闊以的。

然而,這麼多高深地功能,很多前端開發者其實都沒有這樣地需求去接觸。羅列在這裡,是為了讓自己接到類似需求的時候能準確判斷對此類需求能做到什麼程度。

簡單生成點聲音

網頁一般是無聲的,但是當你嘗試去給你的點選產生一個聲音,對於特殊場景下會讓客戶耳目一新。這裡例子主要參考自張鑫旭--利用HTML5 Web Audio API給網頁互動增加聲音給到的例子。我們逐行來分析一下程式碼來看看如何實現這種效果。

1.  window.AudioContext = window.AudioContext || window.webkitAudioContext;
// 生成一個AudioContext物件
2.  var audioCtx = new AudioContext();
// 建立一個OscillatorNode, 它表示一個週期性波形(振盪),基本上來說創造了一個音調
3.  var oscillator = audioCtx.createOscillator();
 // 建立一個GainNode,它可以控制音訊的總音量
4.  var gainNode = audioCtx.createGain();
// 把音量,音調和終節點進行關聯
5.  oscillator.connect(gainNode);
// audioCtx.destination返回AudioDestinationNode物件,表示當前audio context中所有節點的最終節點,一般表示音訊渲染裝置
6.  gainNode.connect(audioCtx.destination);
// 指定音調的型別,其他還有square|triangle|sawtooth
7.  oscillator.type = 'sine';
// 設定當前播放聲音的頻率,也就是最終播放聲音的調調
8.  oscillator.frequency.value = 196.00;
// 當前時間設定音量為0
9.  gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
 // 0.01秒後音量為1
10. gainNode.gain.linearRampToValueAtTime(1, audioCtx.currentTime + 0.01);
// 音調從當前時間開始播放
11. oscillator.start(audioCtx.currentTime);
// 1秒內聲音慢慢降低,是個不錯的停止聲音的方法
12. gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 1);
// 1秒後完全停止聲音
13. oscillator.stop(audioCtx.currentTime + 1);
複製程式碼

oscillator

需要了解上面的程式碼我們需要對音訊有一些基礎的認識。首先,聲音的本質其實就是震動,而震動又務必牽扯到波形,不同的波形會發出不同的聲音。然後相同的波形下還會有不一樣的震動頻率,最終會表現為音調的高低。因此當我們需要生成一個聲音的時候,就需要為它設定波形以及對應的音調,所以你可以這麼理解oscillator就是一個創造音調的玩意。

那麼給一個音調的創造過程就如下:設定波形-->設定頻率

波形

波形主要內建了4種波形,對應發出不同的聲音。主要有sine(正弦波)、square(方波)、triangle(三角波)以及sawtooth鋸齒波。 當然如果有需要還可以使用setPeriodicWave自定義波形。

頻率

頻率這玩意很好理解。就是我們生活中接觸地“do、re、mi、fa、sol、la、si”.數值越小,越低沉;數值越大,越清脆。

connect

其實是h5audio一個十分重要的概念。具體我把它簡單理解為中介軟體的一個概念。例如這個例子,音調產生後經過音量處理的中介軟體然後再將這些聲音結點輸出到揚聲器上。(在其他文章有很介紹得十分詳盡的,在這裡就不太想展開,最下面的外鏈可以找到)

剩下的就是淡入淡出的設定以及播放聲音的內容。這些部分在張大大的部落格中有詳細地闡述,該塊內容也是參考著它的博文進行二次翻譯記錄自己一些理解(很粗淺)然後總結罷了。

玩點錄音

獲取到原始的pcm資料

自己創造聲音有點過於高大上,只播聲音又顯得有點無趣。那麼幹脆來玩一下錄音好了。開啟錄音很簡單,只需要這麼簡單的一行程式碼(當然不考慮相容性咯)

navigator.mediaDevices.getUserMedia({audio: true, video: true})
複製程式碼

可以試一下在瀏覽器控制檯輸入這段程式碼, 你就會看到網站想要呼叫你的攝像頭以及麥克風的請求。點選允許後,你的攝像頭就會亮燈,開啟錄音和錄屏的狀態。

navigator.mediaDevices.getUserMedia(videoObj, (stream) => {}, errBack)

你應該馬上就會問,那麼錄完的音跑到哪裡去了。我們從這個呼叫這個方法後,看到中間其實是有一個回撥函式,讓我們拿到麥克風和攝像頭產生的資料流的。這時候我們可以呼叫AudioContext的介面使得音訊PCM資料在到達目的地前通過不同的處理節點(增益、壓縮等),所以我們需要從這裡來入手。

navigator.mediaDevices.getUserMedia({audio: true}, initRecorder)

function initRecorder(stream) {
    const AudioContext = window.AudioContext
    const audioContext = new AudioContext()
    // 建立MediaStreamAudioSourceNode物件,給定媒體流(例如,來自navigator.getUserMedia例項),然後可以播放和操作音訊。
    const audioInput = audioContext.createMediaStreamSource(stream)
    // 緩衝區大小為4096,控制著多長時間需觸發一次audioprocess事件
    const bufferSize = 4096
    // 建立一個javascriptNode,用於使用js直接操作音訊資料
    // 第一個參數列示每一幀快取的資料大小,可以是256, 512, 1024, 2048, 4096, 8192, 16384,值越小一幀的資料就越小,聲音就越短,onaudioprocess 觸發就越頻繁。4096的資料大小大概是0.085s,就是說每過0.085s就觸發一次onaudioprocess,第二,三個參數列示輸入幀,和輸出幀的通道數。這裡表示2通道的輸入和輸出,當然我也可以採集1,4,5等通道
    const recorder = audioContext.createScriptProcessor(bufferSize, 1, 1)
    // 每個滿足一個分片的buffer大小就會觸發這個回撥函式
    recorder.onaudioprocess = recorderProcess
    // const monitorGainNode = audioContext.createGain()
    // 延遲0.01秒輸出到揚聲器
    // monitorGainNode.gain.setTargetAtTime(音量, audioContext.currentTime, 0.01)
    // monitorGainNode.connect(audioContext.destination)
    // audioInput.connect(monitorGainNode)
    // const recordingGainNode = audioContext.createGain()
    // recordingGainNode.gain.setTargetAtTime(音量, audioContext.currentTime, 0.01)
    // recordingGainNode.connect(audioContext.scriptProcessorNode)
    // 將音訊的資料流輸出到這個jsNode物件中
    audioInput.connect(recorder)
    // 最後先音訊流輸出到揚聲器。(將錄音流原本的輸出位置再定回原來的目標地)
    recorder.connect(audioContext.destination)
}
複製程式碼

做到這一步我們已經能拿到錄音資料了,而且還是按照我們預想的樣子去得到已經分好片的buffer資料。那麼我們終於可以開始愉快地處理我們的錄音流了。

補充一下上面程式碼中註釋的兩段用來控制錄音音量大小以及將錄音聲音實時反饋到揚聲器的兩段方法函式。原理相一致,也就是設定一個處理資料的中介軟體,在錄音裝置最終走到揚聲器(目標地)前進行二進位制的控制

function recorderProcess(e) {
  // 左聲道
  const left = e.inputBuffer.getChannelData(0);
}
複製程式碼

注意在recorderProcess裡面呼叫了一個getChannelData的方法,可以傳入整型,取到對應聲道的資料,進行分別處理。由於我們是單聲道錄製,所以只需要拿到左聲道的資料流即可。

如果這時候你不需要對音訊的輸出再進行控制,已經可以將這段二進位制資料直接用websoket傳輸到後臺去了。

資料處理與轉換

假如真不湊巧,後臺大佬要的資料不是你這玩意的樣子,大佬們對音訊質量有要求:只接受一個取樣率是8khz、位深16的wav檔案。很好,那麼提取關鍵字,我們需要先確認我們這段pcm資料是否8khz以及位深16.最後再把這些二進位制組合起來轉成wav格式。看起來很複雜,沒關係,我們一步一步來。

取樣率(sampleRate)和位深度(bitDepth)

首先,取樣率(sampleRate)是什麼呢,百度一下。

音訊取樣率是指錄音裝置在一秒鐘內對聲音訊號的取樣次數,取樣頻率越高聲音的還原就越真實越自然。在當今的主流採集卡上,取樣頻率一般共分為22.05KHz、44.1KHz、48KHz三個等級,22.05KHz只能達到FM廣播的聲音品質,44.1KHz則是理論上的CD音質界限,48KHz則更加精確一些。

那麼接下來這段程式碼就可以讓你獲取到你麥克風取樣率是多少。

const AudioContext = window.AudioContext
const audioContext = new AudioContext()
// 可讀屬性
console.log(audioContext.sampleRate)
// 44100
複製程式碼

很好,這段輸出代表你的錄音裝置取樣率高到44100HZ,那麼根據需求,你就需要將自己的音訊取樣率降低下來了。然而不幸的是,瀏覽器並不允許去修改錄音時的取樣率,而且不同電腦裝置的表現還不一樣。這意味著,你需要在中間node拿到的二進位制再進行一次處理。那麼怎麼去降低自己的取樣率呢。根據上面百度的資料,你很容易就能發現,取樣率的高低其實只是在一秒內的音訊它的資料點有多少個的問題,我們需要把原來一秒內有44100個點的資料流減少成8000個點的資料流。很簡單嘛,不是麼,不過這個有個小點是需要注意的。

Downsample PCM audio from 44100 to 8000參考這裡的一個回覆: Lets take a simple case of downsampling by a factor of 2. (e.g. 44100->22050). A naive approach would be to just throw away every other sample. But imagine for a second that in the original 44.1kHz file there was a single sine wave present at 20khz. It is well within nyquist (fs/2=22050) for that sample rate. After you throw every other sample away it is still going to be there at 10kHz but now it will be above nyquist (fs/2=11025) and it will alias into your output signal. The final result is that you will have a big fat sine wave sitting at 8975 Hz! In order to avoid this aliasing during downsampling you need to first design a lowpass filter with a cutoff selected according to your decimation ratio. For the example above you would cutoff everything above 11025 first and then decimate.

這裡的大概意思就是單純地抽點是不行的。當然裡面內在邏輯還涉及到頻率波長什麼的,我自然就不太清楚了。有興趣的朋友可以詳細瞭解一下原因。所幸這裡還提供了程式碼的實現方式。

   function interleave(e){
      var t = e.length;
      sampleRate += 0.0;
      outputSampleRate += 0.0;
      var s = 0,
      o = sampleRate / outputSampleRate,
      u = Math.ceil(t * outputSampleRate / sampleRate),
      a = new Float32Array(u);
      for (i = 0; i < u; i++) {
        a[i] = e[Math.floor(s)];
        s += o;
      }
      return a;
    }
複製程式碼

那麼取樣率的問題就解決了。剩下還有兩個問題。將音訊流轉為16位深,這裡就比較簡單了。只要確保你生成的位數足夠就行。比如,8位深的音訊只需要生成一個Uint8Array,16位深就要生成2個的長度。new Unit8Array(bitDepth / 8)

pcm資料轉wav

好不容易終於走到最後一步了。怎樣把pcm資料轉為wav資料。本來以為將是一個極其棘手的問題,但是所幸。pcm->wav的方法非常簡單,將wav檔案以二進位制的方式開啟後,去除前44位的位元組的標頭檔案資訊就是一段pcm資料了。那麼我們只需要把錄音過程中的所有內容都收集起來,然後插入標頭檔案資訊即可。這一步相對於前面來說簡直簡單太多了。

但是我們還有一個問題要解決。“javascript如何操作二進位制資料”。所以接下來就要介紹這幾個玩意了。

Bolb、ArrayBuffer、Unit8Array、DataView

經過漫長地查詢,看原始碼看API查資料,我終於集齊了有關這次操作的所有物件,可以召喚神龍了!

這個幾個物件真的是老衲學了這麼久都沒接觸過幾個,有些甚至聽都沒聽過。所以一個一個來,而且我也只能提供一些我初略的見解。(歡迎有大佬給我訂正,想要具體瞭解還是更適合單獨去搜尋)

ArrayBuffer是個啥玩意呢。ArrayBuffer又稱型別化陣列。型別化陣列,我記得在一篇詳解陣列的文章有看到,但是由於用處不多後面就給忘了。大致的意思就是js的陣列物件,其實並不是像其他面嚮物件語言的實現方式一樣,記憶體不連續而且型別不可控嚴重影響效能。(但是各大瀏覽器引擎都有做優化,所以實際編碼不需要考慮)。而ArrayBuffer則是專門放0和1組成的二進位制資料,所以當你在捕獲到pcm資料的時候,將它列印到控制檯上會看到這是個ArrayBuffer型別的陣列,就是因為這段是二進位制資料。

ArrayBuffer物件並沒有提供任何讀寫記憶體的方法,而是允許在其上方建立“檢視”,從而插入與讀取記憶體中的資料。那麼檢視又是啥呢??

檢視型別 資料型別 佔用位數 佔用位元組 有無符號
Int8Array 整數 8 1
Uint8Array 整數 8 1
Uint8ClampedArray 整數 8 1
Int16Array 整數 16 2
Uint16Array 整數 16 2
Int32Array 整數 32 4
Uint32Array 整數 32 4
Float32Array 整數 32 4 \
Float64Array 浮點數 64 8 \

這對於一個計算機基礎極差的大兄弟來說簡直是噩夢。這麼多玩意,我得怎麼搞。又怎麼選擇,啊咧要崩潰了。但是要操作二進位制呀,求助原始碼庫是一個最簡單的做法。在原始碼裡面就發現這個玩意了。

DataView檢視。為了解決各種硬體裝置、資料傳輸等對預設位元組序的設定不一而導致解碼時候會發生的混亂問題,javascript提供了DataView型別的檢視來讓開發者在對記憶體進行讀寫時手動設定位元組序的型別。於是.wav的檔案頭就要這麼寫。具體想知道怎麼寫還是得百度出來.wav的檔案頭資訊位元組具體如何分配

    var view = new DataView(wav.buffer)

    view.setUint32(0, 1380533830, false) // RIFF identifier 'RIFF'
    view.setUint32(4, 36 + dataLength, true) // file length minus RIFF identifier length and file description length
    view.setUint32(8, 1463899717, false) // RIFF type 'WAVE'
    view.setUint32(12, 1718449184, false) // format chunk identifier 'fmt '
    view.setUint32(16, 16, true) // format chunk length
    view.setUint16(20, 1, true) // sample format (raw)
    view.setUint16(22, this.numberOfChannels, true) // channel count
    view.setUint32(24, this.sampleRate, true) // sample rate
    view.setUint32(28, this.sampleRate * this.bytesPerSample * this.numberOfChannels, true) // byte rate (sample rate * block align)
    view.setUint16(32, this.bytesPerSample * this.numberOfChannels, true) // block align (channel count * bytes per sample)
    view.setUint16(34, this.bitDepth, true) // bits per sample
    view.setUint32(36, 1684108385, false) // data chunk identifier 'data'
    view.setUint32(40, dataLength, true) // data chunk length
複製程式碼

上面的內容就是頭資訊的寫法了。最後將資料以unit8Array的格式寫入一個wav二進位制資料就有了。我們還需要的就是將它轉成檔案物件。

Blob你可能沒聽過,但是File你肯定聽過,因為經常需要form表單傳檔案嘛。那麼你這麼理解,Blob是一種JavaScript的物件型別。HTML5的檔案操作物件,file物件就是Blob的一個分支或說一個子集。

也就是為啥File物件能進行檔案分割上傳,就是利用了Blob操作二進位制資料的方法。

new Bolb(wavData, { type: 'audio/wav' })
複製程式碼

驗證效果

上面的程式碼都是二進位制操作,是不是特別沒資訊。那就驗證一下吧!把生成的blob物件,這樣操作:const url = URL.createObjectURL(blob)然後把它丟到<a>標籤裡面來下載這個檔案。命令列工具:

$ file test.wav
test.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
複製程式碼

完全符合效果。nice,剩下的問題就是回到實時處理pcm資料的問題了。

傳輸arrayBuffer

檔案我們會傳,但是二進位制buffer陣列要怎麼傳遞呢?只有當你使用blob生成後的物件才能作為檔案物件使用。但是當我們還是pcm資料段的時候怎麼來作為一個檔案傳遞過去呢。

這裡只稍微列舉一下自己的嘗試,不一定是個正確的使用方法。這裡使用的是websocket

// client
const formData = new FormData()
formData.append(blob, new Blob(arrayBuffer))

// server
console.log(ctx.args[0]) // ArrayBuffer<xx,xxx>
複製程式碼

做點小優化

Web Worker瞭解一下,由於涉及到檔案的轉碼操作。所以很耗費效能,這時候是時候掏出web Worker來深度優化這玩意。

有關這個的內容,其實看起來很高深但是很簡單,worker就相當於一個處理源資料的方法,然後互動方式是用事件監聽。

這裡僅僅記錄幾個坑,就不詳細介紹:

  1. web worker必須起服務來實現。單純html檔案時不能開啟worker的。worker必須要傳一個.js檔案路徑,且保證同源策略。
  2. 不要向worker去傳遞函式,否則會錯誤。而且瀏覽器還不暴露這個錯誤。排查了好久的說
  3. worker必須傳入一個js的路徑。在vue下,只能把它丟在static檔案去引用了喵。

這裡還有些小問題沒解決

  1. 如何實時地繪製出錄音的音量。首先音量可以取得到的buffer裡面的大小作為分貝參考。但是具體實現方式還不太懂,或者說沒有簡便的實現方式吧。
  2. 前端收到一個buffer的時候想要實時播放如何做到,現在做的方式其實是最後才嵌回去。做了一個小實驗,沒成功。(直接把錄音轉化好的內容轉回ArrayBuffer但是失敗了)。感覺如果有後端來傳遞資料應該可以實現。
audioContext.decodeAudioData(play_queue[index_play], function(buffer) {//解碼成pcm流
            var audioBufferSouceNode = audioContext.createBufferSource();
            audioBufferSouceNode.buffer = buffer;
            audioBufferSouceNode.connect(audioContext.destination);
            audioBufferSouceNode.start(0);
        }, function(e) {
            console.log("failed to decode the file");
        });
複製程式碼
  1. 如何實時地計算到現在音訊的錄製實現。因為是資料流沒辦法轉成音訊物件導致沒能拿到這些方法來獲取。現在比較粗暴的方法就是通過位元組長度 取樣率 位數來計算 但是感覺計算量比較大 不屬於一個好的實現方式?
  2. 改變音調和聲音位置的api沒能成功呼叫起來。
const filterNode = audioContext.createBiquadFilter();
// ...
source.connect(filterNode);
filterNode.connect(source.destination);

const updateFrequency = frequency => filterNode.frequency.value = frequency;

複製程式碼
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));

複製程式碼

node端切割音訊檔案

這裡補充一點小小知識點。node端並沒有特別好的處理音訊的庫。查詢了很久之後發現一個呼叫機器環境ffmpeg來輔助處理的庫。用起來起碼是沒問題的,而且非常全面。(畢竟接觸音訊多的人應該都懂這個玩意)這裡粗糙mark一下自己之前用到的api。

      var command = ffmpeg(filePath)
        // .seekInput(60.0) // 開始切割的時間
        .seekInput(7.875) // 開始切割的時間,延遲7秒?為啥
        .duration(4.125) // 需要切割的音訊時長
        .save(path.join(__dirname, '../../../app/public/test-1.wav'))
        .on('end', () => {
          console.log('finish')
        })
        .run()
複製程式碼

最後,感謝付總、塗老師耐心地和我講解有關音訊的種種問題。感謝前端組各位大佬在我不懂的內容即使自己也沒有過多接觸但是還是耐心和我探討問題。


參考文章:這裡列出的文章都是我查詢資料時看到的不錯的文章。和上面的內容不一定強烈相關,建議對音訊感興趣的朋友完全可以自己看看

一個使用 HTML5 錄音的例子

Getting Started with Web Audio API

Using Recorder.js to capture WAV audio in HTML5 and upload it to your server or download locally

HTML5錄音控制元件

chris-rudmin/opus-recorder

Tutorial: HTML Audio Capture streaming to Node.js (no browser extensions)

[前端教程]HTML5製作好玩的麥克風音量檢測器(Web Audio API)

Downsample PCM audio from 44100 to 8000 DataView Typed Arrays: Binary Data in the Browser Tech Tip: Sample Rate and Bit Depth—An Introduction to Sampling How to convert ArrayBuffer to and from String 用html5-audio-api開發遊戲的3d音效和混音 理解DOMString、Document、FormData、Blob、File、ArrayBuffer資料型別 深入淺出 Web Audio Api

相關文章