Spaces:
Running
Running
| // Offscreen document β runs MediaRecorder (service workers can't use audio APIs) | |
| // | |
| // Records TWO independent streams per chunk: | |
| // - tab audio (chromeMediaSource:'tab') β the other participants, as Meet plays them | |
| // - mic audio (plain getUserMedia) β the user's own voice | |
| // Both recorders are cut on the same 15-minute boundary and sent to background.js | |
| // together once both blobs for that chunk are ready. | |
| let tabStream = null; | |
| let micStream = null; | |
| let micAvailable = false; | |
| let tabRecorder = null; | |
| let micRecorder = null; | |
| let tabChunks = []; | |
| let micChunks = []; | |
| let pendingTabBlob = null; // set once tabRecorder.onstop has a blob for the current chunk | |
| let pendingMicBlob = null; // set once micRecorder.onstop has a blob for the current chunk (or stays null if mic unavailable) | |
| let chunkSent = false; // guards against double-send if both onstop handlers fire | |
| let chunkIndex = 0; | |
| let chunkTimer = null; | |
| let playbackCtx = null; // AudioContext that routes captured audio back to speakers | |
| let diagnosticTimer = null; // periodic heartbeat β logs state even if no mute/unmute event ever fires | |
| const CHUNK_MS = 15 * 60 * 1000; // 15 minutes | |
| // ββ Diagnostics β investigating intermittent audio dropouts during recording. | |
| // Pure logging, no behavior change. mute/unmute fire when the browser/OS decides | |
| // a track can't currently provide data (e.g. device contention) β a direct signal | |
| // for "my voice doesn't go there" if it's caused by two concurrent getUserMedia | |
| // sessions on the same mic. AudioContext statechange covers the tab-audio | |
| // playback-restoration path silently dropping ("can't hear the meeting"). | |
| function instrumentTrack(track, label) { | |
| track.onmute = () => console.warn(`[mirror-offscreen] DIAG: ${label} track MUTED (readyState=${track.readyState})`); | |
| track.onunmute = () => console.log(`[mirror-offscreen] DIAG: ${label} track unmuted`); | |
| track.onended = () => console.warn(`[mirror-offscreen] DIAG: ${label} track ENDED unexpectedly`); | |
| console.log(`[mirror-offscreen] DIAG: ${label} track initial state β muted=${track.muted}, enabled=${track.enabled}, readyState=${track.readyState}`); | |
| } | |
| function startDiagnosticHeartbeat() { | |
| diagnosticTimer = setInterval(() => { | |
| const micTrack = micStream?.getAudioTracks()[0]; | |
| const tabTrack = tabStream?.getAudioTracks()[0]; | |
| console.log('[mirror-offscreen] DIAG heartbeat β', | |
| `mic: muted=${micTrack?.muted} readyState=${micTrack?.readyState} |`, | |
| `tab: muted=${tabTrack?.muted} readyState=${tabTrack?.readyState} |`, | |
| `playbackCtx.state=${playbackCtx?.state}`); | |
| }, 15000); | |
| } | |
| chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { | |
| if (msg.target !== 'offscreen') return; | |
| if (msg.action === 'start_recording') { | |
| console.log('[mirror-offscreen] start_recording received β streamId:', msg.streamId); | |
| startRecording(msg.streamId) | |
| .then(() => sendResponse({ ok: true })) | |
| .catch(e => { | |
| console.error('[mirror-offscreen] ERROR: startRecording failed:', e.message); | |
| sendResponse({ error: e.message }); | |
| }); | |
| return true; | |
| } | |
| if (msg.action === 'stop_recording') { | |
| console.log('[mirror-offscreen] stop_recording received'); | |
| stopRecording(); | |
| sendResponse({ ok: true }); | |
| } | |
| }); | |
| async function startRecording(streamId) { | |
| if (!streamId) { | |
| throw new Error('No streamId provided to startRecording'); | |
| } | |
| console.log('[mirror-offscreen] Requesting tab capture stream...'); | |
| tabStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| mandatory: { | |
| chromeMediaSource: 'tab', | |
| chromeMediaSourceId: streamId, | |
| }, | |
| }, | |
| video: false, | |
| }); | |
| const audioTracks = tabStream.getAudioTracks(); | |
| console.log(`[mirror-offscreen] Tab capture stream acquired β ${audioTracks.length} audio track(s)`); | |
| if (audioTracks.length === 0) { | |
| console.error('[mirror-offscreen] ERROR: Tab capture stream has no audio tracks! Recording will produce empty audio.'); | |
| } else { | |
| audioTracks.forEach((t, i) => { | |
| console.log(`[mirror-offscreen] Audio track ${i}: ${t.label}, enabled=${t.enabled}, muted=${t.muted}`); | |
| instrumentTrack(t, 'tab'); | |
| }); | |
| } | |
| // Route captured audio back to local speakers. | |
| // getUserMedia with chromeMediaSource:'tab' silences the tab's audio output by default β | |
| // without this, the user cannot hear remote participants while recording is active. | |
| try { | |
| playbackCtx = new AudioContext(); | |
| const source = playbackCtx.createMediaStreamSource(tabStream); | |
| source.connect(playbackCtx.destination); | |
| console.log('[mirror-offscreen] Audio routed back to speakers β local playback restored'); | |
| console.log(`[mirror-offscreen] DIAG: playbackCtx initial state=${playbackCtx.state}`); | |
| // Capture a stable reference for the closure β the outer `playbackCtx` variable | |
| // gets reassigned to null in stopRecording() before this AudioContext's own | |
| // final 'closed' statechange event fires, so reading the outer variable here | |
| // throws. Reading .state on the object itself is always safe, even after close(). | |
| const ctxRef = playbackCtx; | |
| ctxRef.onstatechange = () => { | |
| if (ctxRef.state === 'suspended') { | |
| console.warn(`[mirror-offscreen] DIAG: playbackCtx SUSPENDED β you may not be able to hear the meeting right now`); | |
| } else { | |
| console.log(`[mirror-offscreen] DIAG: playbackCtx state changed β ${ctxRef.state}`); | |
| } | |
| }; | |
| } catch (e) { | |
| console.warn('[mirror-offscreen] WARNING: Could not restore local audio playback:', e.message); | |
| } | |
| // Mic capture β best-effort. Permission must already have been granted from a visible | |
| // extension page (side panel bootstraps this before triggering start_recording), since | |
| // this offscreen document cannot itself show a permission prompt. If it fails, log and | |
| // continue with tab-only recording β the app must keep working as before. | |
| try { | |
| console.log('[mirror-offscreen] Requesting microphone stream...'); | |
| micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); | |
| micAvailable = true; | |
| const micTracks = micStream.getAudioTracks(); | |
| console.log(`[mirror-offscreen] Mic stream acquired β ${micTracks.length} audio track(s)`); | |
| micTracks.forEach((t, i) => { | |
| console.log(`[mirror-offscreen] Mic track ${i}: ${t.label}, enabled=${t.enabled}, muted=${t.muted}`); | |
| instrumentTrack(t, 'mic'); | |
| }); | |
| } catch (e) { | |
| micAvailable = false; | |
| micStream = null; | |
| console.error('[mirror-offscreen] ERROR: Mic capture failed β continuing with tab-only recording:', e.message); | |
| } | |
| startDiagnosticHeartbeat(); | |
| chunkIndex = 0; | |
| beginChunk(); | |
| // Every 15 minutes, cut the current chunk and start a new one | |
| chunkTimer = setInterval(() => { | |
| console.log(`[mirror-offscreen] 15-minute chunk boundary β stopping recorder(s) for chunk ${chunkIndex}`); | |
| if (tabRecorder && tabRecorder.state === 'recording') tabRecorder.stop(); | |
| if (micRecorder && micRecorder.state === 'recording') micRecorder.stop(); | |
| }, CHUNK_MS); | |
| } | |
| function beginChunk() { | |
| tabChunks = []; | |
| micChunks = []; | |
| pendingTabBlob = null; | |
| pendingMicBlob = null; | |
| chunkSent = false; | |
| const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') | |
| ? 'audio/webm;codecs=opus' | |
| : 'audio/webm'; | |
| console.log(`[mirror-offscreen] beginChunk ${chunkIndex} β mimeType: ${mimeType}`); | |
| tabRecorder = new MediaRecorder(tabStream, { mimeType }); | |
| tabRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) tabChunks.push(e.data); | |
| }; | |
| tabRecorder.onstop = () => { | |
| console.log(`[mirror-offscreen] tab onstop fired for chunk ${chunkIndex} β chunks: ${tabChunks.length}`); | |
| pendingTabBlob = tabChunks.length > 0 | |
| ? new Blob(tabChunks, { type: tabRecorder.mimeType }) | |
| : null; | |
| if (!pendingTabBlob) { | |
| console.error('[mirror-offscreen] ERROR: No tab audio data captured for this chunk β analysis will NOT run for this chunk.'); | |
| } | |
| maybeSendChunk(); | |
| }; | |
| tabRecorder.onerror = (e) => { | |
| console.error(`[mirror-offscreen] tab MediaRecorder error on chunk ${chunkIndex}:`, e.error?.message || e); | |
| }; | |
| tabRecorder.start(); | |
| if (micAvailable && micStream) { | |
| micRecorder = new MediaRecorder(micStream, { mimeType }); | |
| micRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) micChunks.push(e.data); | |
| }; | |
| micRecorder.onstop = () => { | |
| console.log(`[mirror-offscreen] mic onstop fired for chunk ${chunkIndex} β chunks: ${micChunks.length}`); | |
| pendingMicBlob = micChunks.length > 0 | |
| ? new Blob(micChunks, { type: micRecorder.mimeType }) | |
| : null; | |
| if (!pendingMicBlob) { | |
| console.warn('[mirror-offscreen] WARNING: No mic audio data captured for this chunk β mic transcript will be skipped for this chunk.'); | |
| } | |
| maybeSendChunk(); | |
| }; | |
| micRecorder.onerror = (e) => { | |
| console.error(`[mirror-offscreen] mic MediaRecorder error on chunk ${chunkIndex}:`, e.error?.message || e); | |
| }; | |
| micRecorder.start(); | |
| } else { | |
| micRecorder = null; | |
| } | |
| console.log(`[mirror-offscreen] MediaRecorder(s) started for chunk ${chunkIndex} (mic: ${micAvailable})`); | |
| } | |
| // Waits for both recorders (tab always, mic only if available) to report their blob for the | |
| // current chunk before sending β avoids the background having to correlate two async uploads. | |
| async function maybeSendChunk() { | |
| if (chunkSent) return; | |
| const tabDone = pendingTabBlob !== null || tabRecorder?.state !== 'recording'; | |
| const micDone = !micAvailable || pendingMicBlob !== null || micRecorder?.state !== 'recording'; | |
| if (!tabDone || !micDone) return; | |
| chunkSent = true; | |
| const thisChunkIndex = chunkIndex; | |
| if (!pendingTabBlob) { | |
| // Nothing to analyze this chunk β still advance if the stream is live. | |
| if (tabStream && tabStream.active && chunkTimer !== null) beginChunk(); | |
| return; | |
| } | |
| console.log(`[mirror-offscreen] Chunk ${thisChunkIndex}: tab blob ${(pendingTabBlob.size / 1024).toFixed(1)} KB` + | |
| (pendingMicBlob ? `, mic blob ${(pendingMicBlob.size / 1024).toFixed(1)} KB` : ', no mic blob')); | |
| const tabBase64 = await blobToBase64(pendingTabBlob); | |
| const micBase64 = pendingMicBlob ? await blobToBase64(pendingMicBlob) : null; | |
| console.log(`[mirror-offscreen] Sending audio_chunk ${thisChunkIndex} to background`); | |
| chrome.runtime.sendMessage({ | |
| action: 'audio_chunk', | |
| tabBase64, | |
| tabMimeType: pendingTabBlob.type, | |
| micBase64, | |
| micMimeType: pendingMicBlob ? pendingMicBlob.type : null, | |
| chunkIndex: chunkIndex++, | |
| }); | |
| // If stream is still live, start next chunk immediately | |
| if (tabStream && tabStream.active && chunkTimer !== null) { | |
| beginChunk(); | |
| } | |
| } | |
| async function blobToBase64(blob) { | |
| const arrayBuffer = await blob.arrayBuffer(); | |
| const uint8 = new Uint8Array(arrayBuffer); | |
| let binary = ''; | |
| for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]); | |
| return btoa(binary); | |
| } | |
| function stopRecording() { | |
| clearInterval(chunkTimer); | |
| chunkTimer = null; | |
| clearInterval(diagnosticTimer); | |
| diagnosticTimer = null; | |
| if (tabRecorder && tabRecorder.state === 'recording') { | |
| console.log('[mirror-offscreen] Stopping tab MediaRecorder (final chunk)...'); | |
| tabRecorder.stop(); | |
| } else { | |
| console.warn(`[mirror-offscreen] WARNING: stopRecording called but tab MediaRecorder state is "${tabRecorder?.state}" β no onstop will fire!`); | |
| } | |
| if (micRecorder && micRecorder.state === 'recording') { | |
| console.log('[mirror-offscreen] Stopping mic MediaRecorder (final chunk)...'); | |
| micRecorder.stop(); | |
| } | |
| // Give onstop time to fire before stopping the stream tracks and audio context | |
| setTimeout(() => { | |
| if (tabStream) { | |
| console.log('[mirror-offscreen] Stopping tab stream tracks'); | |
| tabStream.getTracks().forEach(t => t.stop()); | |
| tabStream = null; | |
| } | |
| if (micStream) { | |
| console.log('[mirror-offscreen] Stopping mic stream tracks'); | |
| micStream.getTracks().forEach(t => t.stop()); | |
| micStream = null; | |
| } | |
| micAvailable = false; | |
| if (playbackCtx) { | |
| playbackCtx.close().catch(() => {}); | |
| playbackCtx = null; | |
| } | |
| }, 1500); | |
| } | |