3. 前端工程化概述

好,咱们进入实战环节了。前面两章讲了音频基础知识和浏览器兼容性,这一章咱们直接上手——用浏览器把麦克风的声音录下来。

你可能会想:「录音不就是点一下开始、再点一下停止吗?」嗯,表面上看确实是这样。但实际工程里,格式怎么选、状态怎么切、数据怎么拿,每一步都有坑。我最早做语音识别项目时,就因为在录音格式上选错了,导致后端解析失败,排查了一整天。

今天咱们把 MediaRecorder API 彻底讲透。你学完这一章,能自己写一个健壮的录音模块,直接用到生产环境里。

3.1 MediaRecorder API 核心概念

MediaRecorder 是浏览器原生提供的录音接口。它接收一个 MediaStream(就是咱们 getUserMedia 拿到的流),然后把音频数据编码成指定格式输出。

我个人习惯把它的工作流程拆成三步:

  1. 创建实例:new MediaRecorder(stream, options)
  2. 监听数据:ondataavailable 事件拿到音频块
  3. 控制启停:start() / stop() / pause() / resume()

说白了,它就是浏览器帮我们封装好的录音机。你给它麦克风流,它给你吐二进制数据。

核心要点:MediaRecorder 不是实时流式处理,它是按「时间片」输出数据。你调用 start() 时可以传一个 timeslice 参数,比如 1000 毫秒,这样每 1 秒就会触发一次 ondataavailable。

3.1.1 浏览器兼容性

先泼盆冷水——MediaRecorder 在 Safari 上支持得不太好。尤其是 iOS Safari,直到 14.5 才支持,而且只支持 WebM 格式。但 Safari 又不支持 WebM 播放……你说气不气人?

我建议你这样做:

  • 桌面端:Chrome、Firefox、Edge 都支持得很好
  • 移动端:Android Chrome 没问题,iOS Safari 需要降级方案
  • 降级方案:检测不支持时,用 Web Audio API + Worker 自己编码

注意:不要在生产环境里直接假设所有浏览器都支持 MediaRecorder。一定要做特性检测:typeof MediaRecorder !== 'undefined'。

3.2 录音格式选择:WAV vs WebM

这是个大坑。我见过不少项目,前端录完音后端解析不了,就是因为格式没对齐。

MediaRecorder 支持的格式取决于浏览器和操作系统。常用的就两种:

特性 WAV WebM (Opus)
编码方式 PCM 无压缩 Opus 有损压缩
文件大小 大(1分钟约 5MB) 小(1分钟约 200KB)
浏览器支持 Chrome、Firefox 部分支持 Chrome、Firefox、Edge 全支持
后端解析 简单,几乎所有语言都支持 需要 Opus 解码库
实时性 高,无需解码 低,需要解码后才能处理
适用场景 本地录音、语音识别 网络传输、存储

你可能会问:「那我该用哪个?」

我的建议是:

  • 做语音识别:优先用 WAV。因为大多数语音识别引擎(包括咱们后面要讲的 Web Speech API 和第三方 SDK)都直接吃 PCM 数据。省去解码这一步,延迟更低。
  • 做网络传输:用 WebM。文件小,传输快。后端用 ffmpeg 或者 opus.js 解码就行。
  • 兼容性优先:检测浏览器支持的 MIME 类型,动态选择。

小技巧:检测浏览器支持哪些格式,可以这样写:MediaRecorder.isTypeSupported('audio/webm;codecs=opus')。返回 true 就能用。

3.2.1 如何录制 WAV 格式

这里有个坑——MediaRecorder 默认不直接支持 WAV。Chrome 支持 audio/wav,但 Firefox 不支持。所以如果你想稳定拿到 WAV,得自己动手。

我常用的方案是:

  1. 用 MediaRecorder 录成 WebM
  2. 拿到音频数据后,用 AudioContext.decodeAudioData() 解码
  3. 再用 encodeWAV() 函数转成 WAV 格式

代码大概长这样:

// 1. 录制 WebM
const mediaRecorder = new MediaRecorder(stream, {
  mimeType: 'audio/webm;codecs=opus'
});
const chunks = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = async () => {
  const blob = new Blob(chunks, { type: 'audio/webm' });
  // 2. 解码
  const arrayBuffer = await blob.arrayBuffer();
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
  // 3. 转 WAV
  const wavBlob = encodeWAV(audioBuffer);
  // 现在 wavBlob 就是 WAV 格式了
};

嗯,encodeWAV() 函数网上有很多现成的实现,我一般用这个:Recorderjs 里的 exportWAV 方法。你也可以自己写,核心就是把 Float32Array 转成 16-bit 整数,加上 WAV 头。

性能注意:解码再编码这个过程挺耗 CPU 的。如果录音时间很长(比如超过 5 分钟),建议分段处理,或者直接用 WebM 格式。

3.3 录音状态管理

录音不是简单的「开始/停止」两个状态。实际工程里,状态切换很频繁。我总结了一个状态机:

录音状态机 空闲 (idle) 录制中 (recording) 暂停 (paused) 错误 (error) start() pause() resume() stop() error 事件 error 事件 重置 说明:虚线表示异常状态转移,实线表示正常操作。每次状态切换都要更新 UI 和清理资源。

看到这个状态机了吧?实际开发中,我建议你用 useReducer(React)或者一个简单的状态对象来管理。千万别用多个布尔变量——「isRecording」「isPaused」「isError」这种,组合起来能把你绕晕。

3.3.1 状态管理实战代码

这是我常用的一个状态管理方案:

const RECORDER_STATE = {
  IDLE: 'idle',
  RECORDING: 'recording',
  PAUSED: 'paused',
  ERROR: 'error'
};

class RecorderManager {
  constructor(stream, options = {}) {
    this.stream = stream;
    this.state = RECORDER_STATE.IDLE;
    this.mediaRecorder = null;
    this.chunks = [];
    this.options = options;
  }

  async start() {
    if (this.state !== RECORDER_STATE.IDLE) {
      throw new Error('当前状态不允许开始录制');
    }
    try {
      this.mediaRecorder = new MediaRecorder(this.stream, this.options);
      this.mediaRecorder.ondataavailable = (e) => {
        if (e.data.size > 0) this.chunks.push(e.data);
      };
      this.mediaRecorder.onerror = () => {
        this.state = RECORDER_STATE.ERROR;
        this.options.onError?.('录制出错');
      };
      this.mediaRecorder.start(1000); // 每秒触发一次 ondataavailable
      this.state = RECORDER_STATE.RECORDING;
      this.options.onStateChange?.(this.state);
    } catch (err) {
      this.state = RECORDER_STATE.ERROR;
      this.options.onError?.(err.message);
    }
  }

  pause() {
    if (this.state !== RECORDER_STATE.RECORDING) return;
    this.mediaRecorder.pause();
    this.state = RECORDER_STATE.PAUSED;
    this.options.onStateChange?.(this.state);
  }

  resume() {
    if (this.state !== RECORDER_STATE.PAUSED) return;
    this.mediaRecorder.resume();
    this.state = RECORDER_STATE.RECORDING;
    this.options.onStateChange?.(this.state);
  }

  stop() {
    return new Promise((resolve) => {
      if (this.state === RECORDER_STATE.IDLE) {
        resolve(null);
        return;
      }
      this.mediaRecorder.onstop = () => {
        const blob = new Blob(this.chunks, { type: this.options.mimeType || 'audio/webm' });
        this.chunks = [];
        this.state = RECORDER_STATE.IDLE;
        this.options.onStateChange?.(this.state);
        resolve(blob);
      };
      this.mediaRecorder.stop();
    });
  }

  reset() {
    this.chunks = [];
    this.state = RECORDER_STATE.IDLE;
    this.mediaRecorder = null;
    this.options.onStateChange?.(this.state);
  }
}

这段代码有几个关键点:

  • 状态守卫:每个方法开头都检查当前状态,非法操作直接抛错或忽略
  • 回调通知:状态变化时通过 onStateChange 通知外部,方便 UI 更新
  • 资源清理:stop() 里重置 chunks,避免内存泄漏

经验之谈:我曾经在项目里忘了重置 chunks,结果用户录了 10 次音,每次停止后 chunks 越积越多,最后浏览器直接崩了。所以每次 stop() 或者 reset() 一定要清空数组。

3.4 录音实战:完整示例

好了,理论讲完了,咱们写一个完整的录音组件。这个组件会包含:

  • 获取麦克风权限
  • 创建 MediaRecorder
  • 管理录音状态
  • 输出录音文件
async function startRecording() {
  try {
    // 1. 获取麦克风流
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

    // 2. 检测支持的格式
    let mimeType = 'audio/webm;codecs=opus';
    if (MediaRecorder.isTypeSupported('audio/wav')) {
      mimeType = 'audio/wav';
    } else if (!MediaRecorder.isTypeSupported(mimeType)) {
      mimeType = 'audio/webm';
    }

    // 3. 创建管理器
    const recorder = new RecorderManager(stream, {
      mimeType,
      onStateChange: (state) => {
        console.log('录音状态:', state);
        updateUI(state); // 更新按钮状态、显示录音时长等
      },
      onError: (msg) => {
        console.error('录音错误:', msg);
        alert('录音出错:' + msg);
      }
    });

    // 4. 开始录制
    await recorder.start();

    // 5. 5秒后停止(实际项目中由用户点击触发)
    setTimeout(async () => {
      const blob = await recorder.stop();
      // 现在 blob 就是录音文件,可以上传或本地播放
      const url = URL.createObjectURL(blob);
      const audio = new Audio(url);
      audio.play();
    }, 5000);

  } catch (err) {
    console.error('获取麦克风失败:', err);
    alert('请允许麦克风权限');
  }
}

这段代码可以直接跑。你把它放到浏览器的控制台里试试,点一下就能录音 5 秒,然后自动播放。

安全限制:getUserMedia 必须在用户手势(点击、触摸)中调用。你不能在页面加载时自动请求麦克风权限。这是浏览器的安全策略,为了防恶意录音。

3.5 避坑指南

最后,我把自己踩过的坑总结一下:

  • 坑一:MIME 类型不匹配——你指定的格式浏览器不支持,MediaRecorder 会直接抛错。记得用 isTypeSupported 检测。
  • 坑二:录音时长不准——MediaRecorder 的 start(timeslice) 只是触发频率,不是精确的录音时长。要自己用 Date.now() 计时。
  • 坑三:内存泄漏——每次录音结束后,记得释放 MediaStream 的轨道:stream.getTracks().forEach(track => track.stop())。
  • 坑四:iOS Safari 不支持 WebM 播放——如果你录了 WebM,在 iOS 上没法用 <audio> 直接播放。要么转格式,要么用 AudioContext 解码。
  • 坑五:静音检测——用户可能开了麦克风但不说话。我建议在录音时加一个音量检测,如果连续 3 秒音量低于阈值,自动停止并提示用户。