Chrome扩展关闭弹窗/切换标签后持续录制音频的实现求助
Chrome扩展关闭弹窗/切换标签后持续录制音频的实现求助
你遇到的问题其实是Chrome扩展开发里很常见的坑——你的录音逻辑全放在popup页面里了,但popup是临时页面,一旦关闭或者切换标签,它就会被浏览器销毁,内存里的MediaRecorder和音频流也就跟着没了。要解决这个问题,核心是把录音的核心管理逻辑移到后台持久运行的Service Worker(也就是background.js)里,让它来接管录音任务,而popup只负责和用户交互、发送指令就行。
下面是一套完整的重构方案,帮你实现后台持续录音的需求:
核心思路
- popup仅做UI交互层:负责触发权限请求、获取音频流、发送开始/停止录音的指令,以及同步后台的录音状态,不处理实际的录音逻辑。
- background.js做录音核心层:接收popup传来的音频流,在后台管理MediaRecorder实例、存储音频数据,完成录音停止后的后续处理(或把数据传回popup处理)。
- 状态同步:通过Chrome的消息通信API(
chrome.runtime.sendMessage)实现popup和background的状态同步,确保popup重新打开时能正确显示当前录音状态。
重构后的代码
1. popup.js(仅保留UI和指令逻辑)
**popup.js** const startButton = document.getElementById('start'); const stopButton = document.getElementById('stop'); const downloadButton = document.getElementById('download'); const pendingText = document.getElementById('pending'); const clientNameInput = document.getElementById('clientName'); // 页面加载时同步后台录音状态 document.addEventListener('DOMContentLoaded', async () => { chrome.runtime.sendMessage({ action: "getRecordingStatus" }, (response) => { if (response?.isRecording) { updateUIRecordingState(true); clientNameInput.value = response.clientName || ""; } else { updateUIRecordingState(false); } }); }); // 更新UI状态的工具函数 function updateUIRecordingState(isRecording) { clientNameInput.style.display = isRecording ? "none" : "block"; startButton.style.display = isRecording ? "none" : "block"; stopButton.style.display = isRecording ? "block" : "none"; pendingText.style.display = isRecording ? "block" : "none"; pendingText.innerText = isRecording ? "Recording in progress..." : ""; downloadButton.style.display = "none"; } // 开始录音:获取流后传给后台 startButton.addEventListener('click', async () => { try { const clientName = clientNameInput.value.trim(); if (!clientName) { alert("Please enter a client name before starting the recording."); return; } // 先获取屏幕音频流(保留原权限逻辑) const stream = await getScreenAudioStream(); if (!stream) return; // 通知后台开始录音 chrome.runtime.sendMessage({ action: "startRecording", clientName, stream }, (response) => { if (response.success) { updateUIRecordingState(true); } }); } catch (err) { console.error("Error starting recording:", err); alert("Failed to start recording."); } }); // 停止录音:通知后台并处理后续流程 stopButton.addEventListener('click', () => { chrome.runtime.sendMessage({ action: "stopRecording" }, async (response) => { if (response.success) { updateUIRecordingState(false); pendingText.style.display = "block"; pendingText.innerText = "Pending... Please wait."; // 从后台获取录音数据,在popup中完成转码、上传 chrome.runtime.sendMessage({ action: "getRecordingResult" }, async (result) => { try { if (result.error) { pendingText.innerText = `Error: ${result.error}`; return; } // 转码WebM到WAV pendingText.innerText = "Converting audio..."; const wavBlob = await convertWebMToWav(result.webmBlob); const clientName = clientNameInput.value.trim(); // 上传音频到服务器 pendingText.innerText = "Uploading audio..."; const formData = new FormData(); formData.append('audio', wavBlob, 'recorded-audio.wav'); formData.append('clientName', clientName); await fetch('http://myserver/uploads', { method: 'POST', body: formData }); // 请求生成摘要 pendingText.innerText = "Generating summary..."; const summaryResponse = await fetch('http://myserver/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientName }) }); const summaryData = await summaryResponse.json(); if (summaryData.error) { pendingText.innerText = `Error: ${summaryData.error}`; } else { pendingText.innerText = "Summary Ready!"; downloadButton.style.display = "block"; } } catch (err) { pendingText.innerText = "Error during processing."; console.error(err); } }); } }); }); // 下载摘要逻辑不变 downloadButton.addEventListener('click', () => { const clientName = clientNameInput.value.trim(); if (!clientName) { alert("Error: Client name missing."); return; } fetch(`http://myserver/download_summary?clientName=${encodeURIComponent(clientName)}`) .then(response => response.blob()) .then(blob => { const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${clientName}_summary.txt`; link.click(); window.URL.revokeObjectURL(url); }) .catch(err => console.error("Error downloading summary:", err)); }); // 保留原有的权限和流获取逻辑 async function getScreenAudioStream() { return new Promise(async (resolve, reject) => { try { chrome.storage.local.get(['screenAudioPermissionGranted'], async (result) => { if (result.screenAudioPermissionGranted) { const displayStream = await navigator.mediaDevices.getDisplayMedia({ audio: { echoCancellation: false, noiseSuppression: false } }); resolve(displayStream); } else { const displayStream = await navigator.mediaDevices.getDisplayMedia({ audio: { echoCancellation: false, noiseSuppression: false } }); chrome.storage.local.set({ screenAudioPermissionGranted: true }); resolve(displayStream); } }); } catch (err) { alert('Permission denied for audio capture. Please allow access to proceed.'); reject(err); } }); } // 保留原有的转码逻辑 function convertWebMToWav(webmBlob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async () => { try { const arrayBuffer = reader.result; const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const wavData = encodeWav(audioBuffer); const wavBlob = new Blob([wavData], { type: 'audio/wav' }); resolve(wavBlob); } catch (err) { reject(err); } }; reader.onerror = reject; reader.readAsArrayBuffer(webmBlob); }); } function encodeWav(audioBuffer) { const numChannels = audioBuffer.numberOfChannels; const sampleRate = audioBuffer.sampleRate; const samples = audioBuffer.getChannelData(0); const bufferLength = samples.length; const header = new ArrayBuffer(44); const dataView = new DataView(header); dataView.setUint32(0, 0x46464952, true); // "RIFF" dataView.setUint32(4, 36 + bufferLength * 2, true); dataView.setUint32(8, 0x45564157, true); // "WAVE" dataView.setUint32(12, 0x20746d66, true); // "fmt " dataView.setUint32(16, 16, true); dataView.setUint16(20, 1, true); dataView.setUint16(22, numChannels, true); dataView.setUint32(24, sampleRate, true); dataView.setUint32(28, sampleRate * numChannels * 2, true); dataView.setUint16(32, numChannels * 2, true); dataView.setUint16(34, 16, true); dataView.setUint32(36, 0x61746164, true); // "data" dataView.setUint32(40, bufferLength * 2, true); const wavData = new Uint8Array(header.byteLength + bufferLength * 2); wavData.set(new Uint8Array(header), 0); for (let i = 0; i < bufferLength; i++) { const sample = Math.max(-1, Math.min(1, samples[i])); wavData[44 + i * 2] = (sample * 32767) & 0xFF; wavData[45 + i * 2] = ((sample * 32767) >> 8) & 0xFF; } return wavData; }
2. background.js(核心录音管理)
**background.js** let mediaRecorder; let audioChunks = []; let isRecording = false; let clientName = ""; let recordingResult = null; // 存储录音完成后的WebM Blob // 监听来自popup的消息 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { switch (message.action) { case "startRecording": startRecording(message.clientName, message.stream); sendResponse({ success: true }); break; case "stopRecording": stopRecording().then(() => { sendResponse({ success: true }); }).catch(err => { sendResponse({ success: false, error: err.message }); }); return true; // 异步响应需返回true case "getRecordingStatus": sendResponse({ isRecording, clientName }); break; case "getRecordingResult": sendResponse(recordingResult || { error: "No recording result found" }); break; default: sendResponse({ error: "Unknown action" }); } }); // 后台开始录音 function startRecording(name, stream) { if (isRecording) return; clientName = name; audioChunks = []; isRecording = true; // 创建MediaRecorder并监听数据 mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { audioChunks.push(e.data); } }; mediaRecorder.onstop = () => { // 录音停止后生成WebM Blob recordingResult = { webmBlob: new Blob(audioChunks, { type: 'audio/webm' }), clientName }; // 重置状态 isRecording = false; clientName = ""; mediaRecorder = null; audioChunks = []; }; mediaRecorder.start(); } // 后台停止录音 async function stopRecording() { if (!isRecording || !mediaRecorder) throw new Error("No recording in progress"); mediaRecorder.stop(); // 等待录音停止逻辑执行完成 await new Promise(resolve => { const checkInterval = setInterval(() => { if (!isRecording) { clearInterval(checkInterval); resolve(); } }, 100); }); }
3. manifest.json(确保后台配置正确)
{ "manifest_version": 3, "name": "Background Audio Recorder", "version": "1.0", "permissions": ["storage", "activeTab"], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html", "default_icon": "icon.png" }, "host_permissions": ["http://myserver/*"] }
关键注意事项
- Service Worker的限制:Service Worker是事件驱动的,不会一直常驻内存,但Chrome会在有活跃任务(比如MediaRecorder运行)时保持它的存活。
- 音频流的传递:
getDisplayMedia必须在有用户交互的页面(如popup)中调用,不能直接在Service Worker里触发,所以我们在popup中获取流后再传递给后台。 - 转码逻辑的位置:把转码、上传放在popup中是因为Service Worker对AudioContext的支持可能有兼容性问题,放在普通页面更稳妥。
备注:内容来源于stack exchange,提问作者Hubert Bokszczanin
相关产品推荐
相关产品推荐

