點燈坊

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

從麥克風每次取得固定 SIZE 的 PCM 格式檔案

Sam Xiao's Avatar 2024-02-09

Web API 內建的 MediaRecorder 並無法將麥克風的聲音儲存成 PCM 格式檔案,而 extendable-media-recorder 提供與 MediaRecorder 相同的 interface,但可支援 PCM 格式,透過精確控制 start() 的時間,可取得固定 Size 的 Chunk。

Version

Vue 3.3
extendable-media-recorder 9.1.6
extendable-media-recorder-wav-encoder 7.0.101

Install Package

$ npm install extendable-media-recorder
$ npm install extendable-media-recorder-wav-encoder
  • extendable-media-recorder:提供與原生 MediaRecorder 相同 interface 的新 MediaRecorder
  • extendable-media-recorder-wav-encoder:支援 WAV 格式

Architecture

pcm001

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

Extendable Media Recorder

main.js

import { createApp } from 'vue'
import App from './App.vue'
import { register } from 'extendable-media-recorder'
import { connect } from 'extendable-media-recorder-wav-encoder'

await register(await connect())

createApp(App).mount('#app')

Line 7

await register(await connect())
  • 與原生 MediaRecorder 不同,extendable-media-recorder 所提供的 MediaRecorder 必須先註冊才能使用
  • register() 不能寫在每個頁面的 mounted(),只要 route 改變重新進入該 page,就會造成重複註冊的錯誤,因此只能寫在 main.js 只註冊一次

App.vue

<template>
  <button @click="onStart">Start</button>
  <button @click="onStop">Stop</button>
</template>

<script setup>
import { MediaRecorder } from 'extendable-media-recorder'

const SAMPLE_RATE = 16000
const CHANNEL_COUNT = 1
const SAMPLE_INTERVAL = 250
const CHUNK_SIZE = 16000
const FILE_NAME = 'sample.pcm'

let mediaRecorder = null

let onStart = async () => {
  try {
    let mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })

    let audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })

    let srcNode = new MediaStreamAudioSourceNode(audioContext, { mediaStream })
    let destNode = new MediaStreamAudioDestinationNode(audioContext, {
      channelCount: CHANNEL_COUNT,
    })

    srcNode.connect(destNode)

    mediaRecorder = new MediaRecorder(destNode.stream, {
      mimeType: 'audio/wav',
    })

    mediaRecorder.ondataavailable = e => {
      let size = e.data.size
      let chunk = e.data

      if (size === CHUNK_SIZE) {
        let blob = new Blob([chunk])

        let link = document.createElement('a')
        link.href = URL.createObjectURL(blob)
        link.download = FILE_NAME
        link.click()

        URL.revokeObjectURL(link.href)
      }
    }

    mediaRecorder.start(SAMPLE_INTERVAL)
  } catch (err) {
    console.warn(err)
  }
}

let onStop = () => {
  mediaRecorder.stop()
}
</script>

Line 9

import { MediaRecorder } from 'extendable-media-recorder'
  • 使用 extendable-media-recorder 所提供的 MediaRecorder 取代 Web API 內建的 MediaRecorder

Line 12

const SAMPLE_RATE = 16000
const CHANNEL_COUNT = 1
const SAMPLE_INTERVAL = 250
const CHUNK_SIZE = 16000
const FILE_NAME = 'sample.pcm'

設定可改用的變數:

  • SAMPLE_RATE:取樣頻率
  • CHANNEL_COUNT:Mono channel
  • SAMPLE_INTERVAL:每次取樣時間
  • CHUNK_SIZE:每次取樣的 size
  • FILE_NAME:下載檔名

Line 19

let mediaStream = await navigator.mediaDevices.getUserMedia({
  audio: true,
})
  • 使用 Web API 內建的 navigator.mediaDevices.getUserMedia() 取得 MediaStream

Line 21

let audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })
  • 要改變取樣頻率,必須使用 AudioContext

Line 23

let srcNode = new MediaStreamAudioSourceNode(audioContext, { mediaStream })
let destNode = new MediaStreamAudioDestinationNode(audioContext)
  • AudioConext 必須靠 node 方式運行
  • AudioContextMediaStream 建立 srcNode
  • AudioContext 建立 destNode

Line 28

srcNode.connect(destNode)
  • 使用 connect() 連結 srcNodedestNode

Line 30

mediaRecorder = new MediaRecorder(destNode.stream, {
  mimeType: 'audio/wav',
})
  • 使用 extendable-media-recorder 提供的 MediaRecorder,並改由 destNode 所處理過的 MediaStream
  • 指定 mineTypeaudio/wav

Line 50

mediaRecorder.start(SAMPLE_RATE)
  • 只要精準設定 SAMPLE_RATE 時間,則每次 dataavailable event 被觸發時,都可取得固定 size 的 chunk

Line 56

let onStop = () => {
  mediaRecorder.stop()
}
  • 啟動 MediaRecoder 開始錄音

Line 34

mediaRecorder.ondataavailable = e => {
  let size = e.data.size
  let chunk = e.data

  if (size === CHUNK_SIZE) {
    let blob = new Blob([chunk])

    let link = document.createElement('a')
    link.href = URL.createObjectURL(blob)
    link.download = FILE_NAME
    link.click()

    URL.revokeObjectURL(link.href)
  }
}
  • 觸發 dataavaliable event 取得音訊
  • e.data:取得音訊 array
  • e.data.size:取得 音訊 array 的大小
  • 每次 dataavaliable event 都將 chunk 存檔或傳進 API

Conclusion

  • 只要精準設定 period 時間,則每次 dataavailable event 被觸發時,都可取得固定 size 的 chunk
  • 實務發現只有第一次觸發 dataavailable event 時,chunk size 不太準,但之後都很精準,因此可在 dataavailable event 內多一次檢查