點燈坊

失くすものさえない今が強くなるチャンスよ

從麥克風取得 WAV 格式檔案

Sam Xiao's Avatar 2024-02-09

Web API 內建的 MediaRecorder 並無法將麥克風的聲音儲存成 WAV 格式檔案,而是 Google 自己的 WebM 格式,必須使用 Web Audio API 的 ScriptProcessor 內自己將 WebM 轉成 WAV,並加上 WAV 的 header,才能夠產生 WAV 格式檔案下載。

Version

Vue 3.3

Architecture

wav001

  • MediaStream: 從麥克風取得 stream
  • AudioContext: 產生 16 bit stream
  • AudioContext 產生 SrcNodeScriptNodeDestNode,需使用 connect() 將各 node 連結在一起
  • ScriptNode:將 WebMWAV

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 建立 srcNodescriptNodedestNode
  • srcNode:起始節點
  • scriptNode:將 WebMPCM
  • destNode:結束節點

Line 84

srcNode.connect(scriptNode)
scriptNode.connect(destNode)
  • srcNodescriptNodedestNode 連在一起

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()
}
  • 設定 isRecordingfalse 表示 停止錄音
  • 開始將儲存音訊的 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