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

如何让MediaRecorder在用户切换标签页时持续运行不被浏览器终止?

长时间音频录制保持活跃的解决方案

问题背景

我需要实现一段时长在10分钟至1.5小时之间的用户音频录制功能,要求用户切换到其他标签页时,录制及相关资源仍能保持活跃并继续录制。我已通过React的useContext实现该功能(代码如下),但浏览器会定期节流甚至终止媒体流及相关资源,偶尔还会出现无法触发MediaRecorder的.stop()方法,报错InvalidStateError: The MediaRecorder's state cannot be inactive。除了提示用户停留在当前页面,还有其他方法保持流活跃吗?

现有实现代码

import React, { createContext, useState, useRef, useCallback, useContext, useEffect } from 'react';
import { setup_media_recorder } from '../utils/AudioUtils';
import { AlertContext } from './AlertFlag';

const MicrophoneContext = createContext();
const AudioContext = createContext();

const put_mic_local = (deviceId) => {
  localStorage.setItem("microphone", deviceId);
}

const get_mic_local = () => {
  return localStorage.getItem("microphone");
}

export const AudioProvider = React.memo(({ children }) => {
  const { addAlert } = useContext(AlertContext);
  // Microphone
  const [ selectedMic, setSelectedMic ] = useState(null);
  const [ microphones, setMicrophones ] = useState([]);
  // Audio
  const [ recordingState, setRecordingState ] = useState("mounted");
  const [ recordingTime, setRecordingTime ] = useState(0);
  const recordingTimerRef = useRef(null);

  // Persistent locations
  const audioDeviceRef = useRef(null);
  const mediaRecorderRef = useRef(null);
  const recordedChunksRef = useRef([]);
  const audioCodecs = useRef(null);

  const loadPermissionsAndMicrophones = useCallback(async () => {
    if (recordingState === "initialized") {
      return;
    } else if (recordingState === "ended") {
      restart();
      return;
    } else if (recordingState !== "mounted") {
      throw new Error("Recording state is not mounted.")
    }
    // Grab permissions. If not given, then nothing happens
    try {
      await navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
        stream.getTracks().forEach(track => track.stop());
      });
    }
    catch (error) {
      addAlert("Microphone permission denied", "danger");
      return;
    }
    try {
      await navigator.mediaDevices.enumerateDevices().then(devices => {
        // Ensure there is a deviceId. Otherwise, grabbing a stream from an empty mic id will throw overconstrained
        const audio_devices = devices.filter(device => device.kind === "audioinput" && device.deviceId && device.deviceId !== "");
        const local_mic_id = get_mic_local();
        const selectedLocalMicExists = audio_devices.some(mic => mic.deviceId === local_mic_id);
        
        if (selectedLocalMicExists) {
          setSelectedMic(local_mic_id);
        } else {
          setSelectedMic(audio_devices[0].deviceId);
        }
        setMicrophones(audio_devices);
        setRecordingState("initialized");
      });
    } catch (error) {
      addAlert(error.message, "danger");
      throw new Error(error);
    }
  }, [recordingState]);

  // Swap before recording
  const swapMicrophones = useCallback((deviceId) => {
    const is_acceptable_mic = microphones.some(mic => mic.deviceId === deviceId);
    if (!is_acceptable_mic) {
      addAlert("Microphone is not selectable.", "danger");
      throw new Error("Improper microphone selected");
    }

    setSelectedMic(deviceId);
    put_mic_local(deviceId);
  }, [microphones, recordingState]);

  const startCapture = useCallback(async () => {
    if (recordingState !== "initialized") {
      throw new Error("Recording state is not initialized")
    }

    try {
      if (!selectedMic) {
        addAlert("No microphone selected", "danger");
        throw new Error("No microphone selected");
      }
      // Create source with initial media stream
      const firstAudioStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: selectedMic, echoCancellation: true }});
      audioDeviceRef.current = firstAudioStream;

      const newMediaRecorder = setup_media_recorder(firstAudioStream, (chunk) => { recordedChunksRef.current.push(chunk) })
      mediaRecorderRef.current = newMediaRecorder["media_recorder"];
      audioCodecs.current = newMediaRecorder["audio_codecs"];

      mediaRecorderRef.current.start(5000); // Collect data every second
      setRecordingState('recording');
      recordingTimerRef.current = setInterval(() => {
        setRecordingTime(prev => prev + 1);
      }, 1000);
    } catch (error) {
      // Cleanup if partially initialized
      cleanRecordingStates();
      cleanAudio();
      addAlert("Couldn't begin recording", "danger");
      throw new Error(error)
    }
  }, [recordingState, selectedMic]);


  const pauseCapture = useCallback(() => {
    if (recordingState !== "recording") {
      throw new Error("Cannot pause recording as recording has not started") 
    }

    try {
      mediaRecorderRef.current.pause();
      clearInterval(recordingTimerRef.current);
      setRecordingState('paused');
    }
    catch(error) {
      throw new Error(error);
    }
  }, [recordingState])

  // CHECK IF STREAMS ARE ALIVE AND IF THE RECORDING CAN BE CONTINUED. IF NOT, THEN A FLAG SHOULD BE TRIGGERED TO FORCE USER TO SUBMIT
  const unpauseCapture = useCallback(() => {
    if (recordingState !== "paused") {
      throw new Error("Cannot play recording as recording is not paused");
    }

    try {
      mediaRecorderRef.current.resume();
      recordingTimerRef.current = setInterval(() => {
        setRecordingTime(prev => prev + 1);
      }, 1000);
      setRecordingState('recording');
    } catch (error) {
      throw new Error(error)
    }
  }, [recordingState])

  const stopCapture = useCallback(() => {
    if (recordingState !== "recording" && recordingState !== "paused") {
      throw new Error("Cannot stop recording as recording is not recording or paused")
    }

    try {
      return new Promise((resolve, reject) => {
        mediaRecorderRef.current.onstop = () => {
          let blob;
          if (audioCodecs.current) {
            blob = new Blob(recordedChunksRef.current, {
              type: audioCodecs.current
            });
          } else {
            blob = new Blob(recordedChunksRef.current);
          }
          resolve(blob);
          cleanAudio();
          cleanRecordingStates();
        };
        mediaRecorderRef.current.onerror = (err) => {
          reject(err);
        };
        // This fires final ondataavailable events and triggers `onstop`
        mediaRecorderRef.current.stop();
      });
    } catch (error) {
      throw new Error(error)
    }
  }, [recordingState])

  // Cleanup audio controls and connections
  const cleanAudio = useCallback(() => {
    if (mediaRecorderRef.current) {
      mediaRecorderRef.current.stop();
      mediaRecorderRef.current = null;
    }

    if (audioDeviceRef.current) {
      audioDeviceRef.current.getTracks().forEach((track) => track.stop());
      audioDeviceRef.current = null;
    }
  }, [])

  const cleanRecordingStates = useCallback(() => {
    setRecordingTime(0);

    if (recordingTimerRef.current) {
      clearInterval(recordingTimerRef.current);
    }
    recordedChunksRef.current = [];
    setRecordingState("ended");
  }, [])

  const restart = useCallback(() => {
    if (selectedMic && microphones.length > 0 && recordingState === "ended") {
      setRecordingState("initialized")
    }
  }, [recordingState, microphones, selectedMic])

  useEffect(() => {
    return () => {
      cleanAudio();
      cleanRecordingStates();
    }
  }, [])

  // Exposed contexts
  const microphoneValue = {
    microphones,
    selectedMic,
    loadPermissionsAndMicrophones,
    swapMicrophones,
  };
  
  // Recorder context value
  const recorderValue = {
    recordingState,
    recordingTime,
    audioDeviceRef,
    audioCodecs,
    startCapture,
    pauseCapture,
    unpauseCapture,
    stopCapture,
    restart,
    cleanAudio,
    cleanRecordingStates,
  };
  
  return (
    <MicrophoneContext.Provider value={microphoneValue}>
      <AudioContext.Provider value={recorderValue}>
        {children}
      </AudioContext.Provider>
    </MicrophoneContext.Provider>
  );
})

export const useMicrophone = () => useContext(MicrophoneContext);
export const useRecorder = () => useContext(AudioContext);

export { MicrophoneContext, AudioContext };

解决方案

  • 用Web Worker分离录制核心逻辑
    把MediaRecorder的录制、数据收集逻辑移到Web Worker中。Web Worker不受标签页后台节流影响,能持续运行。主线程仅负责UI状态同步和用户交互,Worker处理流数据的存储与状态维护,避免浏览器因标签页后台终止录制进程。

  • 定期检测并恢复媒体流
    定时检查媒体轨道的readyState,一旦发现轨道变为ended,立即重新请求媒体流并恢复录制。同时监听轨道的ended事件,实时处理流中断:

    // 定时检测
    setInterval(() => {
      if (audioDeviceRef.current) {
        const track = audioDeviceRef.current.getTracks()[0];
        if (track.readyState === 'ended') {
          // 自定义恢复录制逻辑
          restartRecording();
        }
      }
    }, 30000);
    
    // 监听轨道终止事件
    audioDeviceRef.current?.getTracks()[0].addEventListener('ended', () => {
      restartRecording();
    });
    
  • 优化录制状态校验与内存管理
    调用stop()前先校验MediaRecorder的状态,避免无效调用;同时定期将录制块写入IndexedDB或FileSystem Access API,减少内存占用:

    const stopCapture = useCallback(() => {
      const recorder = mediaRecorderRef.current;
      if (!recorder || !['recording', 'paused'].includes(recorder.state)) {
        addAlert('录制状态异常,无法停止', 'danger');
        return Promise.reject(new Error('MediaRecorder状态无效'));
      }
      // 原有stop逻辑...
    }, [recordingState])
    
  • 利用页面可见性API做状态补偿
    监听页面可见性变化,当页面从后台回到前台时,自动检查并恢复录制状态:

    useEffect(() => {
      const handleVisibilityChange = () => {
        if (document.visibilityState === 'visible' && recordingState === 'paused') {
          unpauseCapture().catch(err => {
            addAlert('恢复录制失败,请重新开始', 'danger');
          });
        }
      };
      document.addEventListener('visibilitychange', handleVisibilityChange);
      return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
    }, [recordingState, unpauseCapture]);
    

内容的提问来源于stack exchange,提问作者Kevin Ouyang

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 09:38:11