【JavaScript】實作前端錄音功能

這次客戶有個需求需要做即時語音辨識,這個現在其實要做到很容易,像是 Google 、 Azure 都有提供相關的服務,而且整包 SDK 寫好給你,只要呼叫函式就可以使用了。但這次客戶要配合的語音辨識服務,僅提供 API 使用,沒有整包 SDK 可以用,而且僅支援 pcm-16,16kHz 格式的音訊,所以得自己從麥克風接音訊進來、轉檔、發送後端並取回資料。今天這篇就來講講要怎麼在前端透過 WebRTC 來取得麥克風音訊及處理的過程。
#取得麥克風音訊
首先我們得先確定使用者用的瀏覽器有沒有支援 WebRTC ,若有才可以繼續進行後續動作,否則可能就得請使用者換個瀏覽器了。
if (navigator.mediaDevices) {
console.log('getUserMedia supported.');
} else {
console.log('getUserMedia not supported on your browser!');
}
接著就可以利用 WebRTC 的 getUserMedia 來取得麥克風音訊:
navigator.mediaDevices.getUserMedia({
audio: true
}).then(mediaStream => {
console.log(mediaStream);
}).catch(function (err) {
console.log('The following gUM error occured: ' + err);
});
在使用 getUserMedia 的時候可以指定要回傳的音訊格式,但這個設定好像沒什麼用,因為我把接到的 stream 直接丟到後端後會出錯,但用後面的方式轉過格式後就過了,十分奇妙...
navigator.mediaDevices.getUserMedia({
audio:{
sampleRate: 16000, // 採用率
channelCount: 2, // 聲道數
volume: 1.0 // 音量
}
}).then(mediaStream => {
console.log(mediaStream);
}).catch(function (err) {
console.log('The following gUM error occured: ' + err);
});
如果一切順利,應該可以在 console 看到,有回傳了一個 MediaStream。
#利用 AudioContext 處理音訊
AudioContext 可以理解成一個乘載音訊的容器,我們會利用他的 ScriptProcessorNode 來對音訊進行處理,包含了 onaudioprocess 的 callback。
var audioContext = new (window.AudioContext || window.webkitAudioContext);
為了獲取錄音的的數據,我們要把他 connect 到 ScriptProcessorNode,因此我們要先建立一個 ScriptProcessorNode 的實體:
function createJSNode(audioContext) {
var BUFFER_SIZE = 4096;
var INPUT_CHANNEL_COUNT = 2;
var OUTPUT_CHANNEL_COUNT = 2;
var creator = audioContext.createScriptProcessor || audioContext.createJavaScriptNode;
creator = creator.bind(audioContext);
return creator(BUFFER_SIZE, INPUT_CHANNEL_COUNT, OUTPUT_CHANNEL_COUNT);
}
這邊利用 createScriptProcessor 來建立實體,需要傳入幾個參數
- BUFFER_SIZE
緩衝區大小,通常是 4 KB - INPUT_CHANNEL_COUNT
輸入的聲道數量 - OUTPUT_CHANNEL_COUNT
輸出的聲道數量
在之後的 onAudioProcess 裡面可以透過下面的程式碼取得左右聲道的資料:
var leftChannelData = audioBuffer.getChannelData(0);
var rightChannelData = audioBuffer.getChannelData(1);
因為最後會把音訊轉為 wav 格式,而 wav 在儲存的時候會左右聲道的數據交叉存放:
function interleaveLeftAndRight(left, right) {
var totalLength = left.length + right.length;
var data = new Float32Array(totalLength);
for (let i = 0; i < left.length; i++) {
var k = i * 2;
data[k] = left[i];
data[k + 1] = right[i];
}
return data;
}
最後要來建立 wav 檔案,先建立 header 部分,接著要來把音訊的資料寫入,因為要使用16位二進制,而16位的範圍是 -32768 ~ +32767 ,最大值的 32767 轉換成 16進制則是0x7FFF,把音量乘以這個數值就是實際要儲存下來的值:
function createWavFile(audioData) {
var WAV_HEAD_SIZE = 44;
var 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);
var length = audioData.length;
var index = 44;
var volume = 1;
for (let i = 0; i < length; i++) {
view.setInt16(index, audioData[i] * (0x7FFF * volume), true);
index += 2;
}
return buffer;
}
function writeUTFBytes(view, offset, string) {
var lng = string.length;
for (var i = 0; i < lng; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
上面的函式都完成後,就可以在 onAudioProcess 裡面來使用,先取得左右聲道的陰鬼資料,將其合併起來後建立 wav 檔案,就可以利用 FormData 來傳送給後端了。
function onAudioProcess(event) {
var audioBuffer = event.inputBuffer;
var leftChannelData = audioBuffer.getChannelData(0);
var rightChannelData = audioBuffer.getChannelData(1);
var allData = interleaveLeftAndRight(leftChannelData.slice(0), rightChannelData.slice(0));
var wavBuffer = createWavFile(allData);
var blob = new Blob([new Uint8Array(wavBuffer)]);
var formData = new FormData();
formData.append('data', blob);
formData.append('AsrReferenceId', asrReferenceId);
$.ajax({
url: "/api/STT/SendData",
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (result) {
console.log(result);
}
});
}
最後把剛剛 audioContext 的後半段給補上,先建立 ScriptProcessorNode,並指定 onAudioProcess 的處理函式即完成了。
var mediaNode = audioContext.createMediaStreamSource(stream);
var jsNode = createJSNode(audioContext);
jsNode.connect(audioContext.destination);
jsNode.onaudioprocess = onAudioProcess;
mediaNode.connect(jsNode);
若要停止錄音,則利用下方程式碼即可:
mediaStream.getAudioTracks()[0].stop();
mediaNode.disconnect();
jsNode.disconnect();