PCM 流式音频录制

4 阅读8分钟

一、核心思想

本方案实现了一个高性能的流式音频录制系统,用于实时捕获麦克风输入并转换为 PCM 格式数据。主要核心思想包括:

  1. 使用 AudioWorklet 实现线程隔离:将音频数据格式转换放在独立的 Worklet 线程中执行,避免阻塞主线程,确保 UI 流畅。

  2. 流式输出设计:以事件驱动的方式实时输出音频数据,适合流式传输场景(如实时语音识别、语音聊天等)。

  3. 独立 AudioContext:使用独立的音频上下文,避免与系统音频播放器产生冲突。

  4. Int16 格式输出:将 Web Audio API 的 Float32 格式转换为 Int16 格式,便于网络传输和服务器处理。

二、技术架构

2.1 架构概览

麦克风输入 → MediaStreamAudioWorklet 处理器 → Int16 ArrayBuffer → 事件监听
                                      ↓
                              Float32Int16 转换(独立线程)

2.2 关键技术点

技术点说明
AudioWorklet在独立线程中处理音频,避免阻塞主线程
AudioContext管理音频处理图,可自定义采样率
MediaStream获取麦克风音频流
Float32 → Int16音频数据格式转换,范围从 [-1, 1] 到 [-32768, 32767]
EventTarget使用事件监听模式,方便数据接收

2.3 数据流转

  1. 麦克风采集:通过 getUserMedia 获取麦克风流
  2. Float32 输入:Web Audio API 以 Float32Array 格式提供音频数据(-1.0 到 1.0)
  3. Worklet 转换:在 Worklet 线程中将 Float32 转换为 Int16 ArrayBuffer
  4. 主线程接收:通过 port.postMessage 将转换后的数据发送到主线程
  5. 事件分发:通过 message 事件对外分发音频数据

三、技术细节

3.1 音频格式

  • 输入格式:Float32Array,范围 [-1.0, 1.0]
  • 输出格式:Int16 ArrayBuffer,范围 [-32768, 32767]
  • 声道配置:单声道
  • 缓冲区大小:128 帧(Web Audio API 标准)
  • 采样率:可配置(默认 24000 Hz)

3.2 格式转换算法

Float32 到 Int16 的转换公式:

// 限制范围在 [-1.0, 1.0]
const s = Math.max(-1, Math.min(1, float32Value));

// 转换为 Int16 范围
int16Value = s < 0 ? s * 0x8000 : s * 0x7fff;

3.3 AudioWorklet 配置

参数说明
处理器名称pcm-recorder-processor
输入Float32Array 音频数据
输出Int16 ArrayBuffer
执行位置独立 Worklet 线程

3.4 麦克风配置

{
  audio: {
    echoCancellation: true,    // 回声消除
    noiseSuppression: true,    // 噪声抑制
    autoGainControl: true,     // 自动增益控制
  }
}

四、代码实现

4.1 AudioWorklet 处理器 (recorder-worklet.js)

/**
 * PCM 音频录制处理器
 * @remarks
 * 运行在独立的 Audio Worklet 线程中,负责实时捕获麦克风音频数据并转换为 Int16 格式
 * - 采样率:由 AudioContext 决定(通常为 48000 Hz)
 * - 输入格式:Float32Array(-1.0 到 1.0)
 * - 输出格式:ArrayBuffer (Int16,-32768 到 32767)
 * - 缓冲区大小:128 帧(Web Audio API 标准)
 * - 在 Worklet 线程中完成格式转换,避免阻塞主线程
 */
class RecorderProcessor extends AudioWorkletProcessor {
  /**
   * 将 Float32Array 转换为 Int16 ArrayBuffer
   * @param {Float32Array} float32Data - Float32Array 格式的 PCM 数据,范围为 -1.0 到 1.0
   * @returns {ArrayBuffer} Int16 格式的 ArrayBuffer
   */
  pcmFloat32ToInt16(float32Data) {
    const length = float32Data.length;
    const int16Array = new Int16Array(length);
    for (let i = 0; i < length; i++) {
      // 限制范围在 [-1.0, 1.0]
      const s = Math.max(-1, Math.min(1, float32Data[i]));
      // 转换为 Int16 范围
      int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
    }
    return int16Array.buffer;
  }

  /**
   * 音频处理回调
   * @param {Float32Array[][]} inputs - 输入音频数据([通道][样本])
   * @returns {boolean} 返回 true 保持处理器活跃
   */
  process([input]) {
    // 获取第一个输入源(麦克风)
    // 确保有音频数据
    if (!input || !input.length) return true;
    // 获取第一个声道的数据(单声道录制)
    const [channelData] = input;
    if (!channelData || !channelData.length) return true;
    // 转换为 Int16 ArrayBuffer 并发送到主线程
    const arrayBuffer = this.pcmFloat32ToInt16(channelData);
    this.port.postMessage(arrayBuffer);
    // 返回 true 保持处理器运行
    return true;
  }
}

// 注册处理器
registerProcessor("pcm-recorder-processor", RecorderProcessor);

4.2 主录制器类 (pcm-stream-recorder.ts)

/**
 * PCM 流式音频录制器
 * @remarks
 * 用于录制麦克风输入的 PCM 音频数据,支持事件监听
 * 使用 Web Audio API 的 AudioWorklet 实现高性能录制
 * - 音频格式:ArrayBuffer (Int16,范围 -32768 到 32767)
 * - 声道:单声道
 * - 采样率:可配置(默认为 24000 Hz)
 * - 使用独立的 AudioContext,避免与播放器冲突
 * - 在 AudioWorklet 线程中完成格式转换,不阻塞主线程
 * - 输出的 ArrayBuffer 可直接用于网络传输
 *
 * @example
 * ```typescript
 * const recorder = new PCMStreamRecorder(24000); // 指定采样率
 * recorder.addEventListener('message', (event: MessageEvent<ArrayBuffer>) => {
 *   console.log(event.data); // ArrayBuffer (Int16 格式)
 *   // 直接发送到服务器
 *   sendToServer(event.data);
 * });
 * await recorder.start();
 * recorder.stop();
 * ```
 */
export class PCMStreamRecorder extends EventTarget {
  /**
   * 麦克风媒体流
   */
  private mediaStream: MediaStream | null = null;
  /**
   * 音频源节点
   */
  private sourceNode: MediaStreamAudioSourceNode | null = null;
  /**
   * AudioWorklet 节点
   */
  private workletNode: AudioWorkletNode | null = null;
  /**
   * 私有的 AudioContext
   */
  private audioContext: AudioContext;
  /**
   * 是否正在录制
   * @remarks
   * 通过检查关键资源(sourceNode、workletNode、mediaStream)是否都存在来判断
   */
  get isRecording(): boolean {
    return !!(this.sourceNode && this.workletNode && this.mediaStream);
  }
  /**
   * 采样率
   * @remarks
   * 录制的音频数据采样率,在构造时确定后不再变更
   */
  readonly sampleRate: number;
  /**
   * Worklet 模块是否已加载
   */
  private workletLoaded = false;

  /**
   * 构造函数
   * @param sampleRate - 采样率(默认 24000 Hz)
   */
  constructor(sampleRate = 24000) {
    super();
    this.sampleRate = sampleRate;
    this.audioContext = new AudioContext({ sampleRate });
  }

  /**
   * 开始录制音频
   * @throws {Error} 如果麦克风权限被拒绝或不支持
   * @remarks
   * - 首次调用会请求麦克风权限
   * - 自动加载 AudioWorklet 模块(仅首次加载)
   * - 通过监听 'message' 事件接收音频数据(ArrayBuffer,Int16 格式)
   * - 录制的采样率由构造函数指定
   * - 格式转换在 AudioWorklet 线程中完成,主线程直接接收 ArrayBuffer
   *
   * @example
   * ```typescript
   * const recorder = new PCMStreamRecorder(24000);
   * recorder.addEventListener('message', (event: MessageEvent<ArrayBuffer>) => {
   *   console.log(event.data); // ArrayBuffer (Int16 格式)
   *   // 直接发送到服务器
   *   sendToServer(event.data);
   * });
   * await recorder.start();
   * ```
   */
  async start(): Promise<void> {
    if (this.isRecording) return;
    // 加载 AudioWorklet 模块(仅首次加载)
    if (!this.workletLoaded) {
      await this.audioContext.audioWorklet.addModule(
        new URL("recorder-worklet.js", import.meta.url),
      );
      this.workletLoaded = true;
    }
    const handleMessage = (event: MessageEvent<ArrayBuffer>) => {
      if (!this.isRecording) return;
      // 直接转发 ArrayBuffer(已在 Worklet 中完成转换)
      this.dispatchEvent(new MessageEvent("message", { data: event.data }));
    };
    try {
      // 请求麦克风权限
      this.mediaStream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true, // 回声消除
          noiseSuppression: true, // 噪声抑制
          autoGainControl: true, // 自动增益控制
        },
      });
      // 创建音频源节点
      this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
      // 创建 AudioWorklet 节点
      this.workletNode = new AudioWorkletNode(this.audioContext, "pcm-recorder-processor");
      // 监听来自 Worklet 的音频数据(已转换为 ArrayBuffer)
      this.workletNode.port.addEventListener("message", handleMessage);
      // 启动 port 消息监听
      this.workletNode.port.start();
      // 连接音频处理图
      this.sourceNode.connect(this.workletNode);
      // 注意:此时 isRecording 会自动变为 true(通过 getter 判断资源状态)
    } catch (error) {
      console.error("Recorder error:", error);
      this.stop();
      throw error;
    }
  }

  /**
   * 停止录制音频
   * @remarks
   * 停止录制并释放音频节点和麦克风流资源,但保留 AudioContext
   * 可通过再次调用 start() 重新开始录制
   */
  stop(): void {
    // 断开音频节点
    this.sourceNode?.disconnect();
    this.sourceNode = null;
    this.workletNode?.disconnect();
    this.workletNode = null;
    // 停止麦克风流
    this.mediaStream?.getTracks().forEach((track) => track.stop());
    this.mediaStream = null;
  }

  /**
   * 关闭录制器并释放所有资源
   * @remarks
   * 关闭 AudioContext 和所有相关资源,调用后需要重新创建实例才能再次使用
   */
  cleanup(): void {
    this.stop();
    this.audioContext.close();
  }
}

五、使用示例

5.1 基本使用

// 创建录音器实例(采样率 24000 Hz)
const recorder = new PCMStreamRecorder(24000);

// 监听音频数据
recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
  const audioData = event.data; // Int16 格式的 ArrayBuffer
  console.log("收到音频数据:", audioData.byteLength, "字节");
  // 可以直接发送到服务器
  // await sendAudioToServer(audioData);
});

// 开始录制
try {
  await recorder.start();
  console.log("录音开始");
} catch (error) {
  console.error("录音启动失败:", error);
}

// 停止录制
// recorder.stop();

5.2 流式发送到服务器

class AudioStreamer {
  private recorder: PCMStreamRecorder;
  private ws: WebSocket;

  constructor(sampleRate = 24000, wsUrl: string) {
    this.recorder = new PCMStreamRecorder(sampleRate);
    this.ws = new WebSocket(wsUrl);

    this.recorder.addEventListener("message", async (event: MessageEvent<ArrayBuffer>) => {
      if (this.ws.readyState === WebSocket.OPEN) {
        // 发送二进制数据到服务器
        this.ws.send(event.data);
      }
    });
  }

  async startRecording() {
    await this.recorder.start();
  }

  stopRecording() {
    this.recorder.stop();
  }

  close() {
    this.recorder.cleanup();
    this.ws.close();
  }
}

5.3 带数据缓冲的实现

class BufferedRecorder {
  private recorder: PCMStreamRecorder;
  private buffer: ArrayBuffer[] = [];

  constructor(sampleRate = 24000) {
    this.recorder = new PCMStreamRecorder(sampleRate);

    this.recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
      this.buffer.push(event.data);

      // 当缓冲区达到一定大小时处理
      if (this.buffer.length >= 10) {
        this.processBuffer();
      }
    });
  }

  private processBuffer() {
    const totalSize = this.buffer.reduce((sum, buf) => sum + buf.byteLength, 0);
    const merged = new Int16Array(totalSize);
    let offset = 0;

    for (const buf of this.buffer) {
      const data = new Int16Array(buf);
      merged.set(data, offset);
      offset += data.length;
    }

    console.log("处理音频块:", totalSize / 2, "个采样点");
    this.buffer = [];
  }

  async start() {
    await this.recorder.start();
  }

  stop() {
    // 处理剩余缓冲
    if (this.buffer.length > 0) {
      this.processBuffer();
    }
    this.recorder.stop();
  }
}

六、注意事项

6.1 权限处理

  • 首次调用 start() 会请求麦克风权限
  • 用户拒绝权限会抛出错误
  • 建议在用户点击事件中调用 start(),避免浏览器自动阻止

6.2 资源管理

  • 使用 stop() 停止录制但保留 AudioContext,可重新开始录制
  • 使用 cleanup() 完全释放所有资源,需要重新创建实例
  • 页面卸载时建议调用 cleanup()

6.3 浏览器兼容性

功能ChromeFirefoxSafariEdge
AudioWorklet66+76+14.1+79+
getUserMedia21+17+11+12+

6.4 性能考虑

  • Worklet 在独立线程中执行格式转换,不会阻塞主线程
  • 每次处理 128 帧音频(约 5.3ms @ 24000Hz),实时性良好
  • 避免在 message 事件处理中执行耗时操作

6.5 调试建议

// 监听录制状态
const checkRecordingStatus = () => {
  console.log("录制状态:", recorder.isRecording);
  console.log("采样率:", recorder.sampleRate);
};

setInterval(checkRecordingStatus, 1000);

七、常见问题

Q1: 为什么选择 Int16 格式而不是其他格式?

Int16 格式(16 位 PCM)是音频处理的行业标准,具有以下优势:

  • 兼容性好,大多数音频库都支持
  • 文件体积适中(相比 Float32 小一半)
  • 精度足够满足语音应用需求
  • 便于网络传输

Q2: 采样率应该如何选择?

  • 8000 Hz:电话质量,适合简单语音识别
  • 16000 Hz:语音应用常用,质量和性能平衡
  • 24000 Hz:高质量语音,推荐用于实时语音
  • 44100/48000 Hz:CD 质量,适合音乐应用

Q3: 如何处理音频数据丢失?

message 事件处理中,确保及时处理数据,避免缓冲区溢出:

recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
  // 立即处理数据,不要阻塞
  processAudioData(event.data);
});

Q4: 支持多声道吗?

当前实现为单声道录制。如需多声道,需修改 Worklet 处理器的 process 方法处理多个声道,并根据需求合并或分别输出。