如何實現前端錄音功能

前端實現錄音有兩種方式,一種是使用MediaRecorder,另外一種是使用WebRTC的getUserMedia結合AudioContext,MediaRecorder出現得比較早,只不過Safari/Edge等瀏覽器一直沒有實現,因此兼容性不是很好,而WebRTC已經獲得了全部主流瀏覽器的支持,如Safari 11起就支持了。因此咱們用WebRTC的方式進行錄製。javascript

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

爲實現錄音功能,咱們先從播放本地文件音樂提及,由於有些API是通用的。前端

1. 播放本地音頻文件實現

播放音頻可使用audio標籤,也可使用AudioContext,audio標籤須要一個url,它能夠是一個遠程的http協議的url,也能夠是一個本地的blob協議的url,怎麼建立一個本地的url呢?java

使用如下html作爲說明:node

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

提供一個file input上傳控件讓用戶選擇本地的文件和一個audio標籤準備來播放。當用戶選擇文件後會觸發onchange事件,在onchange回調裏面就能夠拿到文件的內容,以下代碼所示:git

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即原始的二進制內容,把它打印以下所示:github

能夠用這個ArrayBuffer實例化一個Uint8Array就能讀取它裏面的內容,Uint8Array數組裏面的每一個元素都是一個無符號整型8位數字,即0 ~ 255,至關於每1個字節的0101內容就讀取爲一個整數。更多討論能夠見這篇《前端本地文件操做與上傳》。web

這個arrayBuffer能夠轉成一個blob,而後用這個blob生成一個url,以下代碼所示:chrome

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 * 2 + 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文件,可進一步提升效率,避免錄音文件太大後面處理的時候卡住了。

相關文章
相關標籤/搜索