前端實現錄音有兩種方式,一種是使用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檔案,可進一步提高效率,避免錄音檔案太大後面處理的時候卡住了。