如何让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
相关产品推荐
相关产品推荐

