/** * ThirdEye Meet Recorder — Content Script * Injected into meet.google.com pages. * Uses Web Speech API (Chrome built-in) for live transcription. * Buffers transcript and POSTs chunks to ThirdEye backend every CHUNK_INTERVAL_MS. */ const CHUNK_INTERVAL_MS = 30000; // Send a chunk every 30 seconds const MAX_BUFFER_CHARS = 8000; // ~2000 tokens — safe for LLM processing let recognition = null; let isRecording = false; let transcriptBuffer = ""; let meetingId = null; let backendUrl = "http://localhost:8000"; let ingestSecret = "thirdeye_meet_secret_change_me"; let groupId = "meet_sessions"; let chunkTimer = null; let chunkCount = 0; // --- Helpers --- function getMeetingId() { // Extract meeting code from URL: meet.google.com/abc-defg-hij const match = window.location.pathname.match(/\/([a-z]{3}-[a-z]{4}-[a-z]{3})/i); if (match) return match[1]; // Fallback: use timestamp-based ID return `meet_${Date.now()}`; } function getParticipantName() { // Try to find the user's own name from the Meet DOM const nameEl = document.querySelector('[data-self-name]'); if (nameEl) return nameEl.getAttribute('data-self-name'); const profileEl = document.querySelector('img[data-iml]'); if (profileEl && profileEl.alt) return profileEl.alt; return "Unknown"; } // --- Transport --- async function sendChunkToBackend(text, isFinal = false) { if (!text || text.trim().length < 10) return; // Don't send near-empty chunks const payload = { meeting_id: meetingId, group_id: groupId, chunk_index: chunkCount++, text: text.trim(), speaker: getParticipantName(), timestamp: new Date().toISOString(), is_final: isFinal, }; try { const res = await fetch(`${backendUrl}/api/meet/ingest`, { method: "POST", headers: { "Content-Type": "application/json", "X-ThirdEye-Secret": ingestSecret, }, body: JSON.stringify(payload), }); if (!res.ok) { console.warn(`[ThirdEye] Backend rejected chunk: ${res.status}`); } else { console.log(`[ThirdEye] Chunk ${payload.chunk_index} sent (${text.length} chars)`); chrome.runtime.sendMessage({ type: "CHUNK_SENT", chunkIndex: payload.chunk_index, charCount: text.length, meetingId: meetingId, }); } } catch (err) { console.warn(`[ThirdEye] Failed to send chunk: ${err.message}`); // Buffer is NOT cleared on failure — next flush will include this text transcriptBuffer = text + "\n" + transcriptBuffer; } } // --- Periodic flush --- function flushBuffer() { if (transcriptBuffer.trim().length > 0) { sendChunkToBackend(transcriptBuffer); transcriptBuffer = ""; } } function startChunkTimer() { if (chunkTimer) clearInterval(chunkTimer); chunkTimer = setInterval(flushBuffer, CHUNK_INTERVAL_MS); } function stopChunkTimer() { if (chunkTimer) { clearInterval(chunkTimer); chunkTimer = null; } } // --- Web Speech API --- function initSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { console.error("[ThirdEye] Web Speech API not available in this browser."); chrome.runtime.sendMessage({ type: "ERROR", message: "Web Speech API not supported." }); return null; } const rec = new SpeechRecognition(); rec.continuous = true; // Don't stop after first pause rec.interimResults = true; // Get partial results while speaking rec.lang = "en-US"; // Change if needed rec.maxAlternatives = 1; rec.onstart = () => { console.log("[ThirdEye] Speech recognition started."); chrome.runtime.sendMessage({ type: "STATUS", status: "recording", meetingId }); }; rec.onresult = (event) => { let newText = ""; for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; if (result.isFinal) { newText += result[0].transcript + " "; } // We only accumulate FINAL results to avoid duplicates from interim results } if (newText.trim()) { transcriptBuffer += newText; // Guard: if buffer is getting huge, flush early if (transcriptBuffer.length > MAX_BUFFER_CHARS) { flushBuffer(); } } }; rec.onerror = (event) => { console.warn(`[ThirdEye] Speech recognition error: ${event.error}`); if (event.error === "not-allowed") { chrome.runtime.sendMessage({ type: "ERROR", message: "Microphone permission denied. Allow mic access in Chrome settings.", }); } else if (event.error !== "no-speech") { // Restart on non-fatal errors setTimeout(() => { if (isRecording) rec.start(); }, 1000); } }; rec.onend = () => { console.log("[ThirdEye] Speech recognition ended."); if (isRecording) { // Auto-restart: Chrome's Web Speech API stops after ~60s of silence setTimeout(() => { if (isRecording) rec.start(); }, 250); } }; return rec; } // --- Public controls (called from popup via chrome.tabs.sendMessage) --- async function startRecording(config = {}) { if (isRecording) return; // Load config from storage or use provided values const stored = await chrome.storage.sync.get(["backendUrl", "ingestSecret", "groupId"]); backendUrl = config.backendUrl || stored.backendUrl || "http://localhost:8000"; ingestSecret = config.ingestSecret || stored.ingestSecret || "thirdeye_meet_secret_change_me"; groupId = config.groupId || stored.groupId || "meet_sessions"; meetingId = getMeetingId(); chunkCount = 0; transcriptBuffer = ""; isRecording = true; recognition = initSpeechRecognition(); if (!recognition) { isRecording = false; return; } recognition.start(); startChunkTimer(); // Notify backend that a new meeting has started try { await fetch(`${backendUrl}/api/meet/start`, { method: "POST", headers: { "Content-Type": "application/json", "X-ThirdEye-Secret": ingestSecret, }, body: JSON.stringify({ meeting_id: meetingId, group_id: groupId, started_at: new Date().toISOString(), speaker: getParticipantName(), }), }); } catch (err) { console.warn("[ThirdEye] Could not notify backend of meeting start:", err.message); } } async function stopRecording() { if (!isRecording) return; isRecording = false; stopChunkTimer(); if (recognition) { recognition.stop(); recognition = null; } // Send the final buffered chunk marked as final await sendChunkToBackend(transcriptBuffer, true); transcriptBuffer = ""; chrome.runtime.sendMessage({ type: "STATUS", status: "stopped", meetingId }); console.log("[ThirdEye] Recording stopped."); } // --- Message listener (from popup) --- chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "START_RECORDING") { startRecording(msg.config || {}).then(() => sendResponse({ ok: true })); return true; // async } if (msg.type === "STOP_RECORDING") { stopRecording().then(() => sendResponse({ ok: true })); return true; } if (msg.type === "GET_STATUS") { sendResponse({ isRecording, meetingId, bufferLength: transcriptBuffer.length, chunkCount, }); return true; } }); console.log("[ThirdEye] Content script loaded on", window.location.href);