【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

接著就可以利用 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);
宣告 AudioContext

為了獲取錄音的的數據,我們要把他 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);
}
建立 ScriptProcessorNode

這邊利用 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));
    }
}
建立 wav 檔案資訊

上面的函式都完成後,就可以在 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);
        }
    });
}
onAudioProcess 函式

最後把剛剛 audioContext 的後半段給補上,先建立 ScriptProcessorNode,並指定 onAudioProcess 的處理函式即完成了。

var mediaNode = audioContext.createMediaStreamSource(stream);
var jsNode = createJSNode(audioContext);
jsNode.connect(audioContext.destination);
jsNode.onaudioprocess = onAudioProcess;
mediaNode.connect(jsNode);
audioContext 設置

若要停止錄音,則利用下方程式碼即可:

mediaStream.getAudioTracks()[0].stop();
mediaNode.disconnect();
jsNode.disconnect();
停止錄音