Skip to content

如何在浏览器中录音并将录音片段上传

背景

在现代 Web 开发中,浏览器支持通过 Web API 进行音频录制,本文将详细介绍如何使用 MediaRecorder API 进行浏览器端音频录制,并通过 HTTP 请求上传录音片段。我们将从以下几个方面进行介绍

一、获取音频输入权限

在开始录音之前,我们需要确认用户设备上是否有音频输入设备(如麦克风)。这通过 navigator.mediaDevices.enumerateDevices() 方法来获取设备列表,并检查是否包含类型为 audioinput 的设备。

typescript
public async check(): Promise<number> {
  try {
    const devices = await window.navigator.mediaDevices.enumerateDevices();
    const hasAudioInput = devices.some((device) => device.kind === 'audioinput');
    console.log('[SpeechBase] 音频权限', hasAudioInput ? 1 : 0);
    return hasAudioInput ? 1 : 0;
  } catch (err) {
    console.error('[SpeechBase] 异常', err);
    return 0;
  }
}
  • enumerateDevices():列出设备列表,查找是否有音频输入设备。
  • 返回值:1 表示有音频设备,0 表示没有。

二、初始化录音设备并开始录音

在用户设备有音频输入权限的前提下,我们使用 getUserMedia() 获取音频流,并创建一个 MediaRecorder 实例来录制音频。MediaRecorder 会根据传入的音频流开始录制,并支持在录制结束后获取录音数据。

typescript
public async initialize(config: { onStop: (buffers: Blob[]) => void; onError: (msg: string) => void }) {
  const { onStop, onError } = config;

  try {
    this.stopTracks();
    this.buffers = [];
    console.log('[SpeechBase] start initialize');
    this.userMedia = await window.navigator.mediaDevices.getUserMedia({ audio: true });
    console.log('[SpeechBase] init userMedia');
    this.recorder = new window.MediaRecorder(this.userMedia, { audioBitsPerSecond: 64000 });
    console.log('[SpeechBase] init recorder');

    this.recorder.ondataavailable = (event: any) => {
      this.buffers.push(event.data);
    };

    this.recorder.onstop = () => {
      onStop(this.buffers);
      this.buffers.length = 0;
    };

    return true;
  } catch (error) {
    this.userMedia = null;
    this.recorder = null;
    console.error('[SpeechBase] createVoice has error', error);
    onError && onError('打开录音失败');
  }
}
  • getUserMedia({ audio: true }):请求访问音频设备。
  • MediaRecorder:用于录制音频流,支持不同的音频比特率配置。
  • ondataavailable:每当录制到一个音频片段时,会触发此回调并将数据添加到 buffers 数组中。

三、停止录音并上传音频片段

录音完成后,我们需要将音频数据上传到服务器。我们使用 axios 向服务器发送 HTTP 请求,将录制的音频片段上传。音频数据需要转换为 Blob 格式并指定其 MIME 类型。

typescript
public async recognizeAudio(options: { url: string; params: any; data: Blob[] }): Promise<any> {
  const { url, params, data } = options;
  const blob = new Blob(data, { type: 'audio/webm; codecs=opus' });

  try {
    const response = await axios.post(url, blob, {
      headers: {
        'Content-Type': 'audio/x-raw',
        'param': JSON.stringify(params),
      },
      timeout: 5000,
    });
    console.log('[SpeechBase]: ', response.data);
    return response.data;
  } catch (error) {
    console.error('[SpeechBase]: ', error);
    throw error;
  }
}
  • new Blob(data, { type: 'audio/webm; codecs=opus' }):将录音片段转换为 Blob 对象,并指定 MIME 类型。
  • axios.post(url, blob):发送 POST 请求,将音频数据上传到服务器。

四、开始录音和停止录音

我们提供了两个方法:start()close(),分别用于开始和停止录音。start() 方法启动录音,而 close() 方法停止录音并触发 onStop 回调,将录音数据传递给服务器。

typescript
public async start() {
  if (this.recorder && this.recorder.state === 'inactive') {
    this.buffers = [];
    this.recorder.start();
    return;
  }
  console.warn('[SpeechBase] Recorder is already recording or not initialized.');
}

public async close() {
  if (this.recorder && this.recorder.state === 'recording') {
    console.log('[SpeechBase]: stop');
    this.recorder.stop();
    this.buffers = [];
    return;
  }
  console.warn('[SpeechBase] Recorder is already inactive or not initialized.');
}
  • this.recorder.start():开始录音。
  • this.recorder.stop():停止录音,触发 onstop 事件并将音频数据上传。

五、销毁资源的重要性

每次调用 initialize 方法时,都需要确保音频轨道被正确地停止。如果不调用 stopTracks(),会导致一些问题:

  • 多重音频流问题:每次 initialize 时,如果前一个录音会话未结束并且没有停止音频轨道,那么会存在多个音频流并行运行。这不仅会浪费资源,还可能导致应用的性能下降,甚至造成浏览器崩溃。
  • 内存泄漏:音频流(userMedia)和录音器(MediaRecorder)如果没有正确停止和销毁,可能会导致内存泄漏。每次初始化都可能增加额外的音频流和录音器对象,而这些对象在未停止时会持续占用内存。
  • 音频设备冲突:如果前一次的音频设备(如麦克风)没有被释放,可能会造成冲突。下一次尝试获取麦克风权限时,浏览器可能会报错,或者无法正确初始化录音设备。 为了避免这些问题,我们需要在每次调用 initialize 方法之前,先调用 stopTracks() 来停止之前的音频轨道,释放占用的资源。
typescript
public stopTracks() {
  if (this.userMedia && this.userMedia.getTracks) {
    this.userMedia.getTracks().forEach((item: any) => item.stop && item.stop());
    this.userMedia = null;
  }
}
  • this.userMedia.getTracks().forEach((item) => item.stop()):停止音频轨道,确保不再占用硬件资源。
  • this.userMedia = null:释放音频流对象。

六、stop & dispose 区别

在音频录制和资源管理的上下文中,disposestop 都是用于释放或结束资源的操作,但它们有不同的使用场景和目的。以下是这两个方法的区别:

1. stop

stop 方法通常是用来停止正在进行的某个操作或过程。在音频录制的场景中,stop 通常用于停止正在录制的音频流或停止某个正在进行的活动,而不一定涉及到完全销毁或清理所有相关资源。

在你的代码中,stop 方法主要用来停止录音操作。它会调用 MediaRecorderstop() 方法,停止录音,并触发 onstop 回调,通常会将音频数据(Blob[])传递给后续处理函数。

typescript
public async close() {
  if (this.recorder && this.recorder.state === 'recording') {
    console.log('[SpeechBase]: stop');
    this.recorder.stop();  // 停止录音
    this.buffers = [];     // 清空缓存的音频数据
    return;
  }
  console.warn('[SpeechBase] Recorder is already inactive or not initialized.');
}
  • 作用:停止音频录制,并触发相关的回调或后续操作。
  • 用途:停止正在进行的活动(例如录音或视频播放)。

2. dispose

dispose 方法通常是用于彻底清理和释放资源的操作。它不仅停止当前的操作,还会确保所有与该操作相关的资源都被释放和清理掉,以防止内存泄漏或不必要的资源占用。dispose 通常意味着一个对象生命周期的结束,确保不再占用任何资源。dispose 方法用于完全销毁录音相关的资源,确保在不再使用时释放掉所有的音频流、录音器以及其他占用的资源。

typescript
public dispose() {
  console.log('[SpeechBase]: dispose');
  this.close();          // 停止录音
  this.recorder = null;   // 释放录音器
  this.stopTracks();     // 停止音频轨道并释放资源
}
  • 作用:彻底清理和销毁资源,确保对象的所有资源都被释放。
  • 用途:对象生命周期结束时使用,通常用于防止内存泄漏和资源冲突。

主要区别

方法作用使用场景
stop停止正在进行的活动(如录音、播放),通常不释放对象或资源。用于停止当前操作(如停止录音、停止视频播放等)。
dispose释放所有与对象相关的资源并销毁对象,确保没有剩余的内存或资源占用。用于清理和销毁对象,通常在对象不再需要时调用,避免资源泄漏。

举例

  1. stop:只需要停止录音但不打算销毁所有相关资源时,可以使用 stop 方法。例如,用户停止录音时,我们仅需要停止录音并触发回调处理结果,但录音器对象 (recorder) 和音频轨道 (userMedia) 仍然可以被重新使用。
  2. dispose:不再需要录音功能,或者用户退出了录音模块时,应该调用 dispose 方法彻底销毁所有与录音相关的资源,防止内存泄漏或不必要的资源占用。如果每次初始化和销毁都不清理资源,可能会导致多个音频流并行存在,影响系统性能,甚至造成崩溃。

总结

  • stop 是用来停止某个活动(如录音或播放),但它不会销毁资源,适用于短期的停止操作。
  • dispose 是用来销毁对象并释放所有资源,适用于对象生命周期结束时的清理工作,确保没有资源泄漏。

六、综合实现:录音并上传

通过创建一个 SpeechEngine 类,我们封装了上述录音和上传的功能,并可以灵活地配置服务器的 URL 和请求参数。

typescript
class SpeechEngine extends SpeechBase {
  public srOpts: ISROpts = {
    server: {
      sr: ServerOpt.http,
      url: `${location.protocol}`,
    },
    onResult: () => {},
  };
  private isRecording: boolean = false;

  public async sr(srOpts: ISROpts) {
    if (this.isRecording) {
      return;
    }
    this.isRecording = true;
    this.srOpts = { ...this.srOpts, ...srOpts };

    const { onResult, server, serverParams } = this.srOpts;

    await this.initialize({
      onStop: async (data: any) => {
        const { sr = '', url = '' } = server || {};
        if (sr === ServerOpt.http && url) {
          try {
            if (this.isRecording) {
              return;
            }
            const result = await this.recognizeAudio({ url, params: serverParams, data });
            onResult(result);
          } catch (error) {
            if (this.srOpts.onError) { this.srOpts.onError('网络异常'); }
          }
        }
      },
      onError: (msg: string) => {
        this.isRecording = false;
        if (this.srOpts.onError) { this.srOpts.onError(msg); }
      },
    });
    if (this.isRecording) {
      this.start();
      this.srOpts.onStart && this.srOpts.onStart();
    } else {
      this.dispose();
    }
  }
}

通过这种方式,我们实现了浏览器端录音和录音数据上传的完整流程。SpeechEngine 类管理录音的启动、停止、数据上传等功能,并通过回调通知应用程序录音结果。

在 MIT 许可下发布