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

Chrome扩展关闭弹窗/切换标签后持续录制音频的实现求助

Chrome扩展关闭弹窗/切换标签后持续录制音频的实现求助

你遇到的问题其实是Chrome扩展开发里很常见的坑——你的录音逻辑全放在popup页面里了,但popup是临时页面,一旦关闭或者切换标签,它就会被浏览器销毁,内存里的MediaRecorder和音频流也就跟着没了。要解决这个问题,核心是把录音的核心管理逻辑移到后台持久运行的Service Worker(也就是background.js)里,让它来接管录音任务,而popup只负责和用户交互、发送指令就行。

下面是一套完整的重构方案,帮你实现后台持续录音的需求:


核心思路

  1. popup仅做UI交互层:负责触发权限请求、获取音频流、发送开始/停止录音的指令,以及同步后台的录音状态,不处理实际的录音逻辑。
  2. background.js做录音核心层:接收popup传来的音频流,在后台管理MediaRecorder实例、存储音频数据,完成录音停止后的后续处理(或把数据传回popup处理)。
  3. 状态同步:通过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/*"]
}

关键注意事项

  1. Service Worker的限制:Service Worker是事件驱动的,不会一直常驻内存,但Chrome会在有活跃任务(比如MediaRecorder运行)时保持它的存活。
  2. 音频流的传递:getDisplayMedia必须在有用户交互的页面(如popup)中调用,不能直接在Service Worker里触发,所以我们在popup中获取流后再传递给后台。
  3. 转码逻辑的位置:把转码、上传放在popup中是因为Service Worker对AudioContext的支持可能有兼容性问题,放在普通页面更稳妥。

备注:内容来源于stack exchange,提问作者Hubert Bokszczanin

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.15 03:38:01