You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在ReactJS(TypeScript)中分别录制麦克风和扬声器音频到独立文件

如何在ReactJS(TypeScript)中分别录制麦克风和扬声器音频到独立文件

嗨,我刚好在React+TypeScript项目里实现过完全一样的需求!其实核心就是利用浏览器原生的Web Audio API和MediaRecorder API,不用装额外的第三方库,就能轻松把麦克风输入和系统扬声器输出分别录成独立的音频文件。下面是我亲测可行的完整方案:

一、核心思路拆解

我们需要分别获取两个独立的媒体流,再给每个流单独创建录制实例:

  • 麦克风输入流:通过navigator.mediaDevices.getUserMedia获取
  • 系统扬声器输出流:通过navigator.mediaDevices.getDisplayMedia获取(这个API原本用于录屏,但我们可以只请求音频轨道)
    然后给每个流绑定独立的MediaRecorder,分别控制录制状态,最后导出各自的音频文件。

二、完整React+TypeScript组件实现

直接上可运行的代码,我加了详细注释,你可以直接复制到项目里测试:

import { useState, useRef, useCallback } from 'react';

// 定义录制状态的TypeScript类型
type RecordingState = 'idle' | 'recording' | 'paused';

// 定义录制完成的文件类型
type RecordedFile = {
  blob: Blob;
  name: string;
  type: 'microphone' | 'system-audio';
};

const AudioRecorder: React.FC = () => {
  // 组件状态管理
  const [recordingState, setRecordingState] = useState<RecordingState>('idle');
  const [recordedFiles, setRecordedFiles] = useState<RecordedFile[]>([]);
  const [error, setError] = useState<string | null>(null);

  // 用useRef保存录制器实例(避免组件重渲染丢失引用)
  const micRecorderRef = useRef<MediaRecorder | null>(null);
  const systemAudioRecorderRef = useRef<MediaRecorder | null>(null);

  // 临时存储录制的音频数据块
  const micRecordingBlobsRef = useRef<Blob[]>([]);
  const systemAudioRecordingBlobsRef = useRef<Blob[]>([]);

  // 初始化媒体流和录制器
  const initRecorders = useCallback(async () => {
    try {
      setError(null);
      // 1. 获取麦克风媒体流
      const micStream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: false,
      });

      // 2. 获取系统音频流(扬声器输出)
      const systemAudioStream = await navigator.mediaDevices.getDisplayMedia({
        video: false,
        audio: true,
      });

      // 3. 初始化麦克风录制器
      const micRecorder = new MediaRecorder(micStream);
      micRecorder.ondataavailable = (e) => {
        if (e.data.size > 0) micRecordingBlobsRef.current.push(e.data);
      };
      micRecorder.onstop = () => {
        // 生成完整的麦克风音频文件
        const micBlob = new Blob(micRecordingBlobsRef.current, { type: 'audio/webm' });
        setRecordedFiles(prev => [...prev, {
          blob: micBlob,
          name: `麦克风录制_${new Date().toISOString().slice(0, 19)}.webm`,
          type: 'microphone'
        }]);
        micRecordingBlobsRef.current = [];
        // 释放麦克风流资源
        micStream.getTracks().forEach(track => track.stop());
      };
      micRecorderRef.current = micRecorder;

      // 4. 初始化系统音频录制器
      const systemAudioRecorder = new MediaRecorder(systemAudioStream);
      systemAudioRecorder.ondataavailable = (e) => {
        if (e.data.size > 0) systemAudioRecordingBlobsRef.current.push(e.data);
      };
      systemAudioRecorder.onstop = () => {
        // 生成完整的系统音频文件
        const systemAudioBlob = new Blob(systemAudioRecordingBlobsRef.current, { type: 'audio/webm' });
        setRecordedFiles(prev => [...prev, {
          blob: systemAudioBlob,
          name: `系统音频录制_${new Date().toISOString().slice(0, 19)}.webm`,
          type: 'system-audio'
        }]);
        systemAudioRecordingBlobsRef.current = [];
        // 释放系统音频流资源
        systemAudioStream.getTracks().forEach(track => track.stop());
      };
      systemAudioRecorderRef.current = systemAudioRecorder;

    } catch (err) {
      setError(err instanceof Error ? err.message : '获取媒体流失败,请检查权限设置');
      console.error('初始化录制器出错:', err);
    }
  }, []);

  // 开始录制
  const startRecording = useCallback(() => {
    if (recordingState === 'recording') return;
    if (!micRecorderRef.current || !systemAudioRecorderRef.current) {
      setError('请先点击「初始化录制器」');
      return;
    }
    micRecorderRef.current.start();
    systemAudioRecorderRef.current.start();
    setRecordingState('recording');
  }, [recordingState]);

  // 暂停录制
  const pauseRecording = useCallback(() => {
    if (recordingState !== 'recording') return;
    micRecorderRef.current?.pause();
    systemAudioRecorderRef.current?.pause();
    setRecordingState('paused');
  }, [recordingState]);

  // 恢复录制
  const resumeRecording = useCallback(() => {
    if (recordingState !== 'paused') return;
    micRecorderRef.current?.resume();
    systemAudioRecorderRef.current?.resume();
    setRecordingState('recording');
  }, [recordingState]);

  // 停止录制
  const stopRecording = useCallback(() => {
    if (recordingState === 'idle') return;
    micRecorderRef.current?.stop();
    systemAudioRecorderRef.current?.stop();
    setRecordingState('idle');
    micRecorderRef.current = null;
    systemAudioRecorderRef.current = null;
  }, [recordingState]);

  // 下载录制的音频文件
  const downloadFile = useCallback((file: RecordedFile) => {
    const url = URL.createObjectURL(file.blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = file.name;
    a.click();
    URL.revokeObjectURL(url);
  }, []);

  return (
    <div style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
      <h2>麦克风&系统音频独立录制</h2>
      {error && <p style={{ color: 'red' }}>⚠️ {error}</p>}
      
      <div style={{ gap: '1rem', margin: '1rem 0', display: 'flex', flexWrap: 'wrap' }}>
        <button
          onClick={initRecorders}
          disabled={recordingState !== 'idle'}
          style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
        >
          初始化录制器
        </button>
        <button
          onClick={startRecording}
          disabled={recordingState !== 'idle'}
          style={{ padding: '0.5rem 1rem', cursor: 'pointer', backgroundColor: 'green', color: 'white', border: 'none' }}
        >
          开始录制
        </button>
        {recordingState === 'recording' && (
          <button
            onClick={pauseRecording}
            style={{ padding: '0.5rem 1rem', cursor: 'pointer', backgroundColor: 'orange', color: 'white', border: 'none' }}
          >
            暂停
          </button>
        )}
        {recordingState === 'paused' && (
          <button
            onClick={resumeRecording}
            style={{ padding: '0.5rem 1rem', cursor: 'pointer', backgroundColor: 'blue', color: 'white', border: 'none' }}
          >
            恢复
          </button>
        )}
        <button
          onClick={stopRecording}
          disabled={recordingState === 'idle'}
          style={{ padding: '0.5rem 1rem', cursor: 'pointer', backgroundColor: 'red', color: 'white', border: 'none' }}
        >
          停止录制
        </button>
      </div>

      <div style={{ marginTop: '2rem' }}>
        <h3>已录制的文件</h3>
        {recordedFiles.length === 0 ? (
          <p>还没有录制的文件哦</p>
        ) : (
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {recordedFiles.map((file, idx) => (
              <li key={idx} style={{ margin: '0.5rem 0', padding: '0.5rem', border: '1px solid #eee' }}>
                <span>
                  {file.type === 'microphone' ? '🎤 ' : '🔊 '}
                  {file.name}
                </span>
                <button
                  onClick={() => downloadFile(file)}
                  style={{ marginLeft: '1rem', padding: '0.3rem 0.6rem', cursor: 'pointer' }}
                >
                  下载
                </button>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
};

export default AudioRecorder;

三、关键细节说明

  1. 为什么用useRef存MediaRecorder?
    React组件重渲染时,useState变量会重新初始化,而useRef的引用会一直保留,这样我们在录制回调(比如onstop)里才能正确访问到录制器实例。

  2. 音频格式说明
    示例中用的是audio/webm格式,这是MediaRecorder支持的最通用格式,几乎所有现代浏览器都兼容,文件体积也比较小。如果需要转成MP3,可能需要额外的编码库,但日常场景下webm完全够用。

  3. 权限注意事项

  • 用户需要允许麦克风权限和屏幕录制权限(浏览器将系统音频归类到屏幕录制权限范畴)
  • 开发时必须用https或localhost,因为浏览器只在安全上下文里允许访问媒体设备API。
  1. 浏览器兼容性
    支持Chrome 49+、Edge 79+、Firefox 25+、Safari 14.1+,覆盖绝大多数现代浏览器。

备注:内容来源于stack exchange,提问作者Praveen saxena

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.13 16:14:28