如何實現前端錄音功能

人人網FED發表於2018-09-02

前端實現錄音有兩種方式,一種是使用MediaRecorder,另一種是使用WebRTC的getUserMedia結合AudioContext,MediaRecorder出現得比較早,只不過Safari/Edge等瀏覽器一直沒有實現,所以相容性不是很好,而WebRTC已經得到了所有主流瀏覽器的支援,如Safari 11起就支援了。所以我們用WebRTC的方式進行錄製。

利用AudioContext播放聲音的使用,我已經在《Chrome 66禁止聲音自動播放之後》做過介紹,本篇我們會繼續用到AudioContext的API.

為實現錄音功能,我們先從播放本地檔案音樂說起,因為有些API是通用的。

1. 播放本地音訊檔案實現

播放音訊可以使用audio標籤,也可以使用AudioContext,audio標籤需要一個url,它可以是一個遠端的http協議的url,也可以是一個本地的blob協議的url,怎麼建立一個本地的url呢?

使用以下html做為說明:

<input type="file" onchange="playMusic.call(this)" class="select-file">
<audio class="audio-node" autoplay></audio>複製程式碼

提供一個file input上傳控制元件讓使用者選擇本地的檔案和一個audio標籤準備來播放。當使用者選擇檔案後會觸發onchange事件,在onchange回撥裡面就可以拿到檔案的內容,如下程式碼所示:

function playMusic () {
    if (!this.value) {
        return;
    }
    let fileReader = new FileReader();
    let file = this.files[0];
    fileReader.onload = function () {
        let arrayBuffer = this.result;
        console.log(arrayBuffer);
    }
    fileReader.readAsArrayBuffer(this.files[0]);
}複製程式碼

這裡使用一個FileReader讀取檔案,讀取為ArrayBuffer即原始的二進位制內容,把它列印如下所示:

可以用這個ArrayBuffer例項化一個Uint8Array就能讀取它裡面的內容,Uint8Array陣列裡面的每個元素都是一個無符號整型8位數字,即0 ~ 255,相當於每1個位元組的0101內容就讀取為一個整數。更多討論可以見這篇《前端本地檔案操作與上傳》。

這個arrayBuffer可以轉成一個blob,然後用這個blob生成一個url,如下程式碼所示:

fileReader.onload = function () {
    let arrayBuffer = this.result;
    // 轉成一個blob
    let blob = new Blob([new Int8Array(this.result)]);
    // 生成一個本地的blob url
    let blobUrl = URL.createObjectURL(blob);
    console.log(blobUrl);
    // 給audio標籤的src屬性
    document.querySelector('.audio-node').src = blobUrl;
}複製程式碼

主要利用URL.createObjectURL這個API生成一個blob的url,這個url列印出來是這樣的:

blob:null/c2df9f4d-a19d-4016-9fb6-b4899bac630d

然後丟給audio標籤就能播放了,作用相當於一個遠端的http的url.

在使用ArrayBuffer生成blob物件的時候可以指定檔案型別或者叫mime型別,如下程式碼所示:

let blob = new Blob([new Int8Array(this.result)], {
    type: 'audio/mp3' // files[0].type
});複製程式碼

這個mime可以通過file input的files[0].type得到,而files[0]是一個File例項,File有mime型別,而Blob也有,因為File是繼承於Blob的,兩者是同根的。所以在上面實現程式碼裡面其實不需要讀取為ArrayBuffer然後再封裝成一個Blob,直接使用File就行了,如下程式碼所示:

function playMusic () {
    if (!this.value) {
        return;
    }
    // 直接使用File物件生成blob url
    let blobUrl = URL.createObjectURL(this.files[0]);
    document.querySelector('.audio-node').src = blobUrl;
}複製程式碼

而使用AudioContext需要拿到檔案的內容,然後手動進行音訊解碼才能播放。

2. AudioContext的模型

使用AudioContext怎麼播放聲音呢,我們來分析一下它的模型,如下圖所示:

我們拿到一個ArrayBuffer之後,使用AudioContext的decodeAudioData進行解碼,生成一個AudioBuffer例項,把它做為AudioBufferSourceNode物件的buffer屬性,這個Node繼承於AudioNode,它還有connect和start兩個方法,start是開始播放,而在開始播放之前,需要調一下connect,把這個Node連結到audioContext.destination即揚聲器裝置。程式碼如下所示:

function play (arrayBuffer) {
    // Safari需要使用webkit字首
    let AudioContext = window.AudioContext || window.webkitAudioContext,
        audioContext = new AudioContext();
    // 建立一個AudioBufferSourceNode物件,使用AudioContext的工廠函式建立
    let audioNode = audioContext.createBufferSource();
    // 解碼音訊,可以使用Promise,但是較老的Safari需要使用回撥
    audioContext.decodeAudioData(arrayBuffer, function (audioBuffer) {
        console.log(audioBuffer);
        audioNode.buffer = audioBuffer;
        audioNode.connect(audioContext.destination); 
        // 從0s開始播放
        audioNode.start(0);
    });
}
fileReader.onload = function () {
    let arrayBuffer = this.result;
    play(arrayBuffer);
}複製程式碼

把解碼後的audioBuffer列印出來,如下圖所示:

他有幾個對開發人員可見的屬性,包括音訊時長,聲道數量和取樣率。從列印的結果可以知道播放的音訊是2聲道,取樣率為44.1k Hz,時長為196.8s。關於聲音這些屬性的意義可見《從Chrome原始碼看audio/video流媒體實現一》.

從上面的程式碼可以看到,利用AudioContext處理聲音有一個很重要的樞紐元素AudioNode,上面使用的是AudioBufferSourceNode,它的資料來源於一個解碼好的完整的buffer。其它繼承於AudioNode的還有GainNode:用於設定音量、BiquadFilterNode:用於濾波、ScriptProcessorNode:提供了一個onaudioprocess的回撥讓你分析處理音訊資料、MediaStreamAudioSourceNode:用於連線麥克風裝置,等等。這些結點可以用裝飾者模式,一層層connect,如上面程式碼使用到的bufferSourceNode可以先connect到gainNode,再由gainNode connect到揚聲器,就能調整音量了。

如下圖示意:

這些節點都是使用audioContext的工廠函式建立的,如調createGainNode就可以建立一個gainNode.

說了這麼多就是為了錄音做準備,錄音需要用到ScriptProcessorNode.

3. 錄音的實現

上面播放音樂的來源是本地音訊檔案,而錄音的來源是麥克風,為了能夠獲取調起麥克風並獲取資料,需要使用WebRTC的getUserMedia,如下程式碼所示;

<button onclick="record()">開始錄音</button>
<script>
function record () {
    window.navigator.mediaDevices.getUserMedia({
        audio: true
    }).then(mediaStream => {
        console.log(mediaStream);
        beginRecord(mediaStream);
    }).catch(err => {
        // 如果使用者電腦沒有麥克風裝置或者使用者拒絕了,或者連線出問題了等
        // 這裡都會拋異常,並且通過err.name可以知道是哪種型別的錯誤 
        console.error(err);
    })  ;
}
</script>複製程式碼

在呼叫getUserMedia的時候指定需要錄製音訊,如果同時需要錄製視訊那麼再加一個video: true就可以了,也可以指定錄製的格式:

window.navigator.mediaDevices.getUserMedia({
    audio: {
        sampleRate: 44100, // 取樣率
        channelCount: 2,   // 聲道
        volume: 1.0        // 音量
    }
}).then(mediaStream => {
    console.log(mediaStream);
});複製程式碼

呼叫的時候,瀏覽器會彈一個框,詢問使用者是否允許使用用麥克風:

如果使用者點了拒絕,那麼會拋異常,在catch裡面可以捕獲到,而如果一切順序的話,將會返回一個MediaStream物件:

它是音訊流的抽象,把這個流用來初始化一個MediaStreamAudioSourceNode物件,然後把這個節點connect連線到一個JavascriptProcessorNode,在它的onaudioprocess裡面獲取到音訊資料,然後儲存起來,就得到錄音的資料。

如果想直接把錄的音直接播放出來的話,那麼只要把它connect到揚聲器就行了,如下程式碼所示:

function beginRecord (mediaStream) {
    let audioContext = new (window.AudioContext || window.webkitAudioContext);
    let mediaNode = audioContext.createMediaStreamSource(mediaStream);
    // 這裡connect之後就會自動播放了
    mediaNode.connect(audioContext.destination);
}複製程式碼

但一邊錄一邊播的話,如果沒用耳機的話容易產生迴音,這裡不要播放了。

為了獲取錄到的音的資料,我們把它connect到一個javascriptProcessorNode,為此先建立一個例項:

function createJSNode (audioContext) {
    const BUFFER_SIZE = 4096;
    const INPUT_CHANNEL_COUNT = 2;
    const OUTPUT_CHANNEL_COUNT = 2;
    // createJavaScriptNode已被廢棄
    let creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
    creator = creator.bind(audioContext);
    return creator(BUFFER_SIZE,
                    INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}複製程式碼

這裡是使用createScriptProcessor建立的物件,需要傳三個引數:一個是緩衝區大小,通常設定為4kB,另外兩個是輸入和輸出頻道數量,這裡設定為雙聲道。它裡面有兩個緩衝區,一個是輸入inputBuffer,另一個是輸出outputBuffer,它們是AudioBuffer例項。可以在onaudioprocess回撥裡面獲取到inputBuffer的資料,處理之後,然後放到outputBuffer,如下圖所示:

例如我們可以把第1步播放本音訊用到的bufferSourceNode連線到jsNode,然後jsNode再連線到揚聲器,就能在process回撥裡面分批處理聲音的資料,如降噪。當揚聲器把4kB的outputBuffer消費完之後,就會觸發process回撥。所以process回撥是不斷觸發的。

在錄音的例子裡,是要把mediaNode連線到這個jsNode,進而拿到錄音的資料,把這些資料不斷地push到一個陣列,直到錄音終止了。如下程式碼所示:

function onAudioProcess (event) {
    console.log(event.inputBuffer);
}
function beginRecord (mediaStream) {
    let audioContext = new (window.AudioContext || window.webkitAudioContext);
    let mediaNode = audioContext.createMediaStreamSource(mediaStream);
    // 建立一個jsNode
    let jsNode = createJSNode(audioContext);
    // 需要連到揚聲器消費掉outputBuffer,process回撥才能觸發
    // 並且由於不給outputBuffer設定內容,所以揚聲器不會播放出聲音
    jsNode.connect(audioContext.destination);
    jsNode.onaudioprocess = onAudioProcess;
    // 把mediaNode連線到jsNode
    mediaNode.connect(jsNode);
}複製程式碼

我們把inputBuffer列印出來,可以看到每一段大概是0.09s:

也就是說每隔0.09秒就會觸發一次。接下來的工作就是在process回撥裡面把錄音的資料持續地儲存起來,如下程式碼所示,分別獲取到左聲道和右聲道的資料:

function onAudioProcess (event) {
    let audioBuffer = event.inputBuffer;
    let leftChannelData = audioBuffer.getChannelData(0),
        rightChannelData = audioBuffer.getChannelData(1);
    console.log(leftChannelData, rightChannelData);
}複製程式碼

列印出來可以看到它是一個Float32Array,即陣列裡的每個數字都是32位的單精度浮點數,如下圖所示:

這裡有個問題,錄音的資料到底表示的是什麼呢,它是取樣採來的表示聲音的強弱,聲波被麥克風轉換為不同強度的電流訊號,這些數字就代表了訊號的強弱。它的取值範圍是[-1, 1],表示一個相對比例。

然後不斷地push到一個array裡面:

let leftDataList = [],
    rightDataList = [];
function onAudioProcess (event) {
    let audioBuffer = event.inputBuffer;
    let leftChannelData = audioBuffer.getChannelData(0),
        rightChannelData = audioBuffer.getChannelData(1);
    // 需要克隆一下
    leftDataList.push(leftChannelData.slice(0));
    rightDataList.push(rightChannelData.slice(0));
}複製程式碼

最後加一個停止錄音的按鈕,並響應操作:

function stopRecord () {
    // 停止錄音
    mediaStream.getAudioTracks()[0].stop();
    mediaNode.disconnect();
    jsNode.disconnect();
    console.log(leftDataList, rightDataList);
}複製程式碼

把儲存的資料列印出來是這樣的:

是一個普通陣列裡面有很多個Float32Array,接下來它們合成一個單個Float32Array:

function mergeArray (list) {
    let length = list.length * list[0].length;
    let data = new Float32Array(length),
        offset = 0;
    for (let i = 0; i < list.length; i++) {
        data.set(list[i], offset);
        offset += list[i].length;
    }
    return data;
}
function stopRecord () {
    // 停止錄音
    let leftData = mergeArray(leftDataList),
        rightData = mergeArray(rightDataList);
}複製程式碼

那為什麼一開始不直接就弄成一個單個的,因為這種Array不太方便擴容。一開始不知道陣列總的長度,因為不確定要錄多長,所以等結束錄音的時候再合併一下比較方便。

然後把左右聲道的資料合併一下,wav格式儲存的時候並不是先放左聲道再放右聲道的,而是一個左聲道資料,一個右聲道資料交叉放的,如下程式碼所示:

// 交叉合併左右聲道的資料
function interleaveLeftAndRight (left, right) {
    let totalLength = left.length + right.length;
    let data = new Float32Array(totalLength);
    for (let i = 0; i < left.length; i++) {
        let k = i * 2;
        data[k] = left[i];
        data[k + 1] = right[i];
    }
    return data;
}複製程式碼

最後建立一個wav檔案,首先寫入wav的頭部資訊,包括設定聲道、取樣率、位聲等,如下程式碼所示:

function createWavFile (audioData) {
    const WAV_HEAD_SIZE = 44;
    let buffer = new ArrayBuffer(audioData.length + WAV_HEAD_SIZE),
        // 需要用一個view來操控buffer
        view = new DataView(buffer);
    // 寫入wav頭部資訊
    // RIFF chunk descriptor/identifier
    writeUTFBytes(view, 0, 'RIFF');
    // RIFF chunk length
    view.setUint32(4, 44 + audioData.length * 2, true);
    // RIFF type
    writeUTFBytes(view, 8, 'WAVE');
    // format chunk identifier
    // FMT sub-chunk
    writeUTFBytes(view, 12, 'fmt ');
    // format chunk length
    view.setUint32(16, 16, true);
    // sample format (raw)
    view.setUint16(20, 1, true);
    // stereo (2 channels)
    view.setUint16(22, 2, true);
    // sample rate
    view.setUint32(24, 44100, true);
    // byte rate (sample rate * block align)
    view.setUint32(28, 44100 * 2, true);
    // block align (channel count * bytes per sample)
    view.setUint16(32, 2 * 2, true);
    // bits per sample
    view.setUint16(34, 16, true);
    // data sub-chunk
    // data chunk identifier
    writeUTFBytes(view, 36, 'data');
    // data chunk length
    view.setUint32(40, audioData.length * 2, true);
}
function writeUTFBytes (view, offset, string) {
    var lng = string.length;
    for (var i = 0; i < lng; i++) { 
        view.setUint8(offset + i, string.charCodeAt(i));
    }
}
複製程式碼

接下來寫入錄音資料,我們準備寫入16位位深即用16位二進位制表示聲音的強弱,16位表示的範圍是 [-32768, +32767],最大值是32767即0x7FFF,錄音資料的取值範圍是[-1, 1],表示相對比例,用這個比例乘以最大值就是實際要儲存的值。如下程式碼所示:

function createWavFile (audioData) {
    // 寫入wav頭部,程式碼同上
    // 寫入PCM資料
    let length = audioData.length;
    let index = 44;
    let volume = 1;
    for (let i = 0; i < length; i++) {
        view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
        index += 2;
    }
    return buffer;
}複製程式碼

最後,再用第1點提到的生成一個本地播放的blob url就能夠播放剛剛錄的音了,如下程式碼所示:

function playRecord (arrayBuffer) {
    let blob = new Blob([new Uint8Array(arrayBuffer)]);
    let blobUrl = URL.createObjectURL(blob);
    document.querySelector('.audio-node').src = blobUrl;
}
function stopRecord () {
    // 停止錄音
    let leftData = mergeArray(leftDataList),
        rightData = mergeArray(rightDataList);
    let allData = interleaveLeftAndRight(leftData, rightData);
    let wavBuffer = createWavFile(allData);
    playRecord(wavBuffer);
}複製程式碼

或者是把blob使用FormData進行上傳。

整一個錄音的實現基本就結束了,程式碼參考了一個錄音庫RecordRTC

4. 小結

回顧一下,整體的流程是這樣的:

先呼叫webRTC的getUserMediaStream獲取音訊流,用這個流初始化一個mediaNode,把它connect連線到一個jsNode,在jsNode的process回撥裡面不斷地獲取到錄音的資料,停止錄音後,把這些資料合併換算成16位的整型資料,並寫入wav頭部資訊生成一個wav音訊檔案的記憶體buffer,把這個buffer封裝成Blob檔案,生成一個url,就能夠在本地播放,或者是藉助FormData進行上傳。這個過程理解了就不是很複雜了。

本篇涉及到了WebRTC和AudioContext的API,重點介紹了AudioContext整體的模型,並且知道了音訊資料實際上就是聲音強弱的記錄,儲存的時候通過乘以16位整數的最大值換算成16位位深的表示。同時可利用blob和URL.createObjectURL生成一個本地資料的blob連結。

RecordRTC錄音庫最後面還使用了webworker進行合併左右聲道資料和生成wav檔案,可進一步提高效率,避免錄音檔案太大後面處理的時候卡住了。


相關文章