前端音频处理入门 (1)- 录音

写在前面

本专栏主要整理前端音视频处理相关的内容,会由浅到深的逐步解析应用,可能会设计音视频频解码、音视频频图像、转码、WASM 等相关内容,总之 DEMO 写到哪儿,内容整理到哪儿 。

录音是一个非常通用的功能,DEMO 见效快,作为入手最为容易, 就让我们先从 API 开始吧。

API

录音会使用到的 API 主要有三个,分别是 mediaDevices,MediaRecorder 与 AudioContext。

mediaDevices

mediaDevices 接口提供访问连接媒体输入的设备,如照相机和麦克风,及屏幕共享等,通过它可以获取硬件资源。也就是说,你想要录音,就需要从这个接口获取权限。

在调用如下代码时,浏览器会向用户申请麦克风权限。

window.navigator.mediaDevices.getUserMedia({
        audio: 'audio',
}).then(audioStream => {

MediaRecorder

MediaRecorder 接口提供媒体录制的能力,通过建立数据流的方式提供音视频处理基础。不过该 API 在 iOS 上的支持并不太好,在 14.3 beta 版本以上才提供支持,详细情况可以阅读 WebKit 的 MediaRecorder API 文章,目前的进度标记为开发中。

使用上通过读取 audioStream 完成处理。

const mediaChunks = []
mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorder.ondataavailable = ({ data }) => {
    mediaChunks.push(data);
mediaRecorder.onstop = () => {
    const blobProperty = {"audio/webm; codecs=opus" };
    const blob = new Blob(mediaChunks, blobProperty);
    const url = URL.createObjectURL(blob);
mediaRecorder.start();

这里有一个坑,就是 start 过后,并不一定会立即进入录音状态。如果你采用的是有线设备连接,那么麦克风的响应速度会非常快。但如果你使用的是蓝牙设备,那很可能会有相当长的设备响应时间。

那什么时候设备真的完成就绪了呢?实际上是在 onstart 接口之后,录音设备彻底完成就绪。

 mediaRecorder.onstart = () => {
};

到此为止,录音功能就基本完成了。但这样录音是不是有点儿单调呢,通常我们见到的录音界面会绘制一个起伏不定的波形图,那如果想要画图,就得再通过 AudioContext API 解析读取的数据。

AudioContext

AudioContext API 仅仅是 Web Audio API 中的一项,用于创建音频处理的上下文环境。通过 createAnalyser API 创建 AnalyserNode 分析节点,可以获取音频节点上信息,大致就像下面这样,绘制一个声音的波形图像。

const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(mediaStream);
source.connect(analyser);
const bufferLength = analyser.fftSize; // 默认为2048
const backgroundColor = 'white'
const strokeColor = 'black'
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);

有了以上的 API 基础后,我们就可以编写一个录音 Demo 了。

Demo

在 React 17 的基础上,编写一个用于录音的 Hooks,把逻辑全部抽象封装进去。

import { useCallback, useRef, useState } from "react";
export const useMediaRecorder = (options) => {
    const {
        audio,
        onStart = () => {},
        onStop = () => {},
    } = options
  const mediaRecorder = useRef(null);
  const mediaChunks = useRef([]);
  const mediaStream = useRef(null);
  const [status, setStatus] = useState("idle");
  const [mediaBlobUrl, setMediaBlobUrl] = useState(null);
  const [error, setError] = useState("NONE");
  const getMediaStream = useCallback(async () => {
    setStatus("acquiring_media");
    try {
      const audioStream = await window.navigator.mediaDevices.getUserMedia({
        audio,
      mediaStream.current = audioStream;
      setStatus("idle");
    } catch (error) {
      setError(error.name);
      setStatus("idle");
  }, [audio]);
  const startRecording = async () => {
    setError("NONE");
    if (!mediaStream.current) {
      await getMediaStream();
    if (mediaStream.current) {
      const isStreamEnded = mediaStream.current
        .getTracks()
        .some((track) => track.readyState === "ended");
      if (isStreamEnded) {
        await getMediaStream();
      mediaRecorder.current = new MediaRecorder(mediaStream.current);
      mediaRecorder.current.ondataavailable = onRecordingActive;
      mediaRecorder.current.onstart = () => {
          onStart(mediaStream.current)
      mediaRecorder.current.onstop = onRecordingStop;
      mediaRecorder.current.onerror = () => {
        setError("NO_RECORDER");
        setStatus("idle");
      mediaRecorder.current.start();
      setStatus("recording");
  const onRecordingActive = ({ data }) => {
    mediaChunks.current.push(data);
  const onRecordingStop = () => {
    const [chunk] = mediaChunks.current;
    const blobProperty = { type: chunk.type || "audio/webm; codecs=opus" };
    const blob = new Blob(mediaChunks.current, blobProperty);
    const url = URL.createObjectURL(blob);
    setStatus("stopped");
    setMediaBlobUrl(url);
    onStop(url, blob);
  const pauseRecording = () => {
    if (mediaRecorder.current && mediaRecorder.current.state === "recording") {
      mediaRecorder.current.pause();
  const resumeRecording = () => {
    if (mediaRecorder.current && mediaRecorder.current.state === "paused") {
      mediaRecorder.current.resume();
  const stopRecording = () => {
    if (mediaRecorder.current) {
      if (mediaRecorder.current.state !== "inactive") {
        setStatus("stopping");
        mediaRecorder.current.stop();
        mediaStream.current &&
          mediaStream.current.getTracks().forEach((track) => track.stop());
        mediaChunks.current = [];
  return {
    error,
    startRecording,
    pauseRecording,
    resumeRecording,
    stopRecording,
    mediaBlobUrl,
    status,
    stream: mediaStream.current,

在此基础上,再封装一个绘制波形图的函数。

const renderAudioGraph= (analyser, canvas) => {
  const height = 200
  const width = 200
  const bufferLength = analyser.fftSize; // 默认为2048
  const backgroundColor = 'white'
  const strokeColor = 'black'
  const dataArray = new Uint8Array(bufferLength);
  analyser.getByteTimeDomainData(dataArray);
  const canvasCtx = canvas.getContext("2d");
  canvasCtx.clearRect(0, 0, width, height);
  canvasCtx.fillStyle = backgroundColor;
  canvasCtx.fillRect(0, 0, width, height);
  canvasCtx.lineWidth = 2;
  canvasCtx.strokeStyle = strokeColor;
  canvasCtx.beginPath();
  const sliceWidth = Number(width) / bufferLength;
  let x = 0;
  canvasCtx.moveTo(x, height / 2);
  for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128.0;
      const y = v * height / 2;
      canvasCtx["lineTo"](x, y);
      x += sliceWidth;
  canvasCtx.lineTo(width, height / 2);
  canvasCtx.stroke();

最后就是在组件中直接使用,整体就大功告成了。

function App() {
  const canvasRef = useRef()
  const {
    status,
    startRecording,
    stopRecording,
    mediaBlobUrl,
    stream,
  } = useMediaRecorder({
    audio: true,
  useEffect(() => {
    if (!stream) {
      return;
    const audioContext = new AudioContext();
    const analyser = audioContext.createAnalyser();
    const source = audioContext.createMediaStreamSource(stream);
    source.connect(analyser);
    const renderCurve = () => {
      renderAudioGraph(analyser, canvasRef.current)
      if (status === 'recording') {
        window.requestAnimationFrame(renderCurve);
      } else {
        console.log('no rendering')
    renderCurve()
  }, [status, stream])
  return (
    <div className="App">
        <button onClick={status === 'recording' ? stopRecording : startRecording}>
          {status === 'recording' ? "停止" :  "录制" }
        </button>
        <canvas
          ref={canvasRef}
          height={200}
          width={200}
          style={{width: 200, height: 200}}