Mirror-Backend / frontend /public /offscreen.js
Harsh200415's picture
Fix crash + noise in audio diagnostic logging
3b255ec
Raw
History Blame Contribute Delete
12.3 kB
// 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);
}