前端實現錄音有兩種方式,一種是使用MediaRecorder,另外一種是使用WebRTC的getUserMedia結合AudioContext,MediaRecorder出現得比較早,只不過Safari/Edge等瀏覽器一直沒有實現,因此兼容性不是很好,而WebRTC已經獲得了全部主流瀏覽器的支持,如Safari 11起就支持了。因此咱們用WebRTC的方式進行錄製。javascript
利用AudioContext播放聲音的使用,我已經在《Chrome 66禁止聲音自動播放以後》作過介紹,本篇咱們會繼續用到AudioContext的API.html
爲實現錄音功能,咱們先從播放本地文件音樂提及,由於有些API是通用的。前端
播放音頻可使用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須要拿到文件的內容,而後手動進行音頻解碼才能播放。
使用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.
上面播放音樂的來源是本地音頻文件,而錄音的來源是麥克風,爲了可以獲取調起麥克風並獲取數據,須要使用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。
回顧一下,總體的流程是這樣的:
先調用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文件,可進一步提升效率,避免錄音文件太大後面處理的時候卡住了。