Web API 內建的 MediaRecorder
並無法將麥克風的聲音儲存成 WAV
格式檔案,而是 Google 自己的 WebM
格式,必須使用 Web Audio API 的 ScriptProcessor
內自己將 WebM
轉成 WAV
,並加上 WAV
的 header,才能夠產生 WAV
格式檔案下載。
Version
Vue 3.3
Architecture
MediaStream
: 從麥克風取得 streamAudioContext
: 產生 16 bit stream- 由
AudioContext
產生SrcNode
、ScriptNode
與DestNode
,需使用connect()
將各 node 連結在一起 ScriptNode
:將WebM
轉WAV
ScriptProcessor
<template>
<button @click="onStart">Start</button>
<button @click="onStop">Stop</button>
</template>
<script setup>
const SAMPLE_RATE = 16000
const FILE_NAME = 'sample.wav'
let isRecording = false
let chunks = []
let floatTo16BitPCM = (output, offset, input) => {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]))
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
}
let writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
let encodeWAV = samples => {
let buffer = new ArrayBuffer(44 + samples.length * 2)
let view = new DataView(buffer)
/* RIFF identifier */
writeString(view, 0, 'RIFF')
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true)
/* RIFF type */
writeString(view, 8, 'WAVE')
/* format chunk identifier */
writeString(view, 12, 'fmt ')
/* format chunk length */
view.setUint32(16, 16, true)
/* sample format (raw) */
view.setUint16(20, 1, true)
/* channel count */
view.setUint16(22, 1, true)
/* sample rate */
view.setUint32(24, SAMPLE_RATE, true)
/* byte rate (sample rate * block align) */
view.setUint32(28, SAMPLE_RATE * 4, true)
/* block align (channel count * bytes per sample) */
view.setUint16(32, 1 * 2, true)
/* bits per sample */
view.setUint16(34, 16, true)
/* data chunk identifier */
writeString(view, 36, 'data')
/* data chunk length */
view.setUint32(40, samples.length * 2, true)
floatTo16BitPCM(view, 44, samples)
return view
}
let processPCMData = () => {
let depth16bitArrayBuffer = encodeWAV(chunks)
let blob = new Blob([depth16bitArrayBuffer])
let link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = FILE_NAME
link.click()
URL.revokeObjectURL(link.href)
}
let onStart = async () => {
isRecording = true
try {
let mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
let audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })
let srcNode = audioContext.createMediaStreamSource(mediaStream)
let scriptNode = audioContext.createScriptProcessor(4096, 1, 1)
let destNode = audioContext.createMediaStreamDestination()
srcNode.connect(scriptNode)
scriptNode.connect(destNode)
scriptNode.onaudioprocess = e => {
let inputData = e.inputBuffer.getChannelData(0)
if (isRecording === true) {
for (let x of inputData) {
chunks.push(x)
}
}
}
} catch (err) {
console.warn(err)
}
}
let onStop = () => {
isRecording = false
processPCMData()
}
</script>
Line 7
const SAMPLE_RATE = 16000
const FILE_NAME = 'sample.wav'
設定可改變的變數:
SAMPLE_RATE
:取樣頻率FILE_NAME
:下載檔名
Line 10
let isRecording = false
let chunks = []
isRecording
:是否正在錄音chunks
:儲存音訊的chunks
array
Line 73
let onStart = async () => {
isRecording = true
}
- 當按下 Start 時,將
isRecording
設定為true
表示開始錄音
Line 77
let mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
- 使用 Web API 內建的
navigator.mediaDevices.getUserMedia()
取得MediaStream
Line 78
let audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })
- 使用
AudioContext
產生 16 bit 的 stream
Line 80
let srcNode = audioContext.createMediaStreamSource(mediaStream)
let scriptNode = audioContext.createScriptProcessor(4096, 1, 1)
let destNode = audioContext.createMediaStreamDestination()
- 由
AudioContext
建立srcNode
、scriptNode
與destNode
srcNode
:起始節點scriptNode
:將WebM
轉PCM
destNode
:結束節點
Line 84
srcNode.connect(scriptNode)
scriptNode.connect(destNode)
- 將
srcNode
、scriptNode
與destNode
連在一起
Line 87
scriptNode.onaudioprocess = e => {
let inputData = e.inputBuffer.getChannelData(0)
if (isRecording === true) {
for (let x of inputData) {
chunks.push(x)
}
}
}
- 麥克風的聲音會不斷地從
audioprocess
event 傳入 - 由
getChannelData()
傳入0
,取得 Mono Channel 數據 - 由
isRecording
判斷是否在錄音 - 將 inputData 依序塞入儲存音訊的
chunks
array
Line 100
let onStop = () => {
isRecording = false
processPCMData()
}
- 設定
isRecording
為false
表示停止錄音
- 開始將儲存音訊的
chunks
寫入WAV
檔案
Line 63
let processPCMData = () => {
let depth16bitArrayBuffer = encodeWAV(chunks)
let blob = new Blob([depth16bitArrayBuffer])
let link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = FILE_NAME
link.click()
URL.revokeObjectURL(link.href)
}
- 處理成
WAV
格式並下載
Line 26
let encodeWAV = samples => {
let buffer = new ArrayBuffer(44 + samples.length * 2)
let view = new DataView(buffer)
/* RIFF identifier */
writeString(view, 0, 'RIFF')
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true)
/* RIFF type */
writeString(view, 8, 'WAVE')
/* format chunk identifier */
writeString(view, 12, 'fmt ')
/* format chunk length */
view.setUint32(16, 16, true)
/* sample format (raw) */
view.setUint16(20, 1, true)
/* channel count */
view.setUint16(22, 1, true)
/* sample rate */
view.setUint32(24, sampleRate, true)
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * 4, true)
/* block align (channel count * bytes per sample) */
view.setUint16(32, 1 * 2, true)
/* bits per sample */
view.setUint16(34, 16, true)
/* data chunk identifier */
writeString(view, 36, 'data')
/* data chunk length */
view.setUint32(40, samples.length * 2, true)
floatTo16BitPCM(view, 44, samples)
return view
}
- 產生
WAV
檔案的header
Line 20
let writeString = (view, offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
- 將字串寫以 8 bit 寫入
Line 13
let floatTo16BitPCM = (output, offset, input) => {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]))
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
}
- 將
WebM
轉成PCM
Conclusion
- 使用 Audio API 的
ScriptProcessor
就可將WebM
轉成WAV
,且不需使用MediaRecorder