-
Notifications
You must be signed in to change notification settings - Fork 3
Description
We'd like to implement an example of a browser extension compatible with:
- Chromium-based Browsers (Chrome, Edge, Brave, ...)
- Firefox (Gecko-based)
- Safari (KHTML/Webkit based)
The extension should be compatible with Manifest V3 only. See: https://developer.chrome.com/docs/extensions/develop/migrate
We should support:
- Background scripts - with long-lived support (living in worker scope, not being intercepted by browser/paused) - this is relevant for long-lived command & control extensions (automating website use via code execution via content scripts) - if the background script lives for an infinite time, it can pull tasks from a defuss-rpc server listening on a server - effectively implementing "Agentic Browser MCP".
- IPC Message passing with BIG files (using copy-free passing) - this is to be able to move AI model files between execution contexts (allows for embedding models to run in-browser / background mutli-threaded).
- Classic communication between background script and main world script. Let's also make it possible to use this via
defuss-rpcas a transport layer (we need to extenddefuss-rpcwith transport layer abstractions for this; more transport layers will be WebSocket, WebRPC data channel later...). - Content script injection (injecting code into an active tab, returning result, passing back to background script) - with the script injection: Rendering a FAB (floating action button) - clicking it opens a modal using defuss-shadcn (this is to prepare for defuss developer tools).
- Rendering a UI using defuss in Developer Tools as a tab (this is to prepare for defuss native developer tools).
- Rendering a toolbar button and on click showing a defuss-rendered popup/overlay. This should render the background script logs as a demo example. The idea is, that using this, the user can observe and control agentic activity.
Over the years, I have already implemented many of the above - but we need to bring this together into one, defuss-native solution.
Code references:
-
Manifest V3 with a configuration that works with all browsers and based on extensive tooling for cross-browser support: https://github.com/kyr0/redaktool/blob/main/manifest.json https://github.com/kyr0/redaktool/blob/main/package.json#L10 https://github.com/kyr0/redaktool/blob/main/vite.config.js#L3
-
Cross-browser polyfill (specifically for older Browsers. Nowadays we might want to remove it?): https://github.com/kyr0/redaktool/blob/main/src/browser-polyfill.js
-
Automation scripts for building / provisioning: https://github.com/kyr0/redaktool/tree/main/scripts
-
Hacking browsers into thinking the background script shall never die (continuous use via AudioWorklet) - the code is effectively a no-op but the work passed to threads skips disposing the background worker task...: https://github.com/kyr0/redaktool/blob/main/audio-processor.js https://github.com/kyr0/redaktool/blob/main/audio-processor.html
-
MessageChannel pipelining: https://github.com/kyr0/redaktool/blob/main/message-channel.js
-
Content script pre-hook to get hold of any event in a website BEFORE the website even loads: https://github.com/kyr0/redaktool/blob/main/src/events-prehook.ts
-
Message Passing / Communication - generic: https://github.com/kyr0/redaktool/blob/main/src/message-channel.tsx
-
Background worker - generic: https://github.com/kyr0/redaktool/blob/main/src/worker.ts
-
LLM-affine types etc. - need to be stripped for only whats necessary here: https://github.com/kyr0/redaktool/blob/main/src/shared.ts
-
Content Script - generic code for injection methods: https://github.com/kyr0/redaktool/blob/main/src/content-script.tsx
-
Much more, extensive browser extension and content script coding - fully working: https://github.com/kyr0/redaktool/tree/main/src/lib
Orchestrator code (for connecting to a local command and control server):
// Orchestrator for handling AI jobs via WebSocket
console.log('Orchestrator: Loaded');
const WS_URL = 'ws://localhost:31337';
let socket = null;
let reconnectInterval = 1000;
function connect() {
console.log('Orchestrator: Connecting to', WS_URL);
socket = new WebSocket(WS_URL);
socket.onopen = () => {
console.log('Orchestrator: Connected');
reconnectInterval = 1000; // Reset backoff
};
socket.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'new_job') {
handleJob(msg.job);
}
} catch (e) {
console.error('Orchestrator: Error parsing message', e);
}
};
socket.onclose = () => {
console.log('Orchestrator: Disconnected. Reconnecting in', reconnectInterval);
setTimeout(connect, reconnectInterval);
reconnectInterval = Math.min(reconnectInterval * 2, 30000);
};
socket.onerror = (err) => {
console.error('Orchestrator: WebSocket error', err);
socket.close();
};
}
async function handleJob(job) {
console.log('Orchestrator: Received job', job);
const { id, model, messages, browserTools } = job;
try {
// 1. Execute Browser Tools
const jobStartTime = Date.now();
const toolStats = [];
const toolResults = [];
if (browserTools && Array.isArray(browserTools)) {
log('Orchestrator', `Executing ${browserTools.length} browser tools...`);
// Execute sequentially for now to avoid race conditions/focus stealing
for (const tool of browserTools) {
const toolStartTime = Date.now();
try {
log('Orchestrator', `Running tool: ${tool.type}`);
const executor = Registry.getToolExecutor(tool.type);
const result = await executor.execute(tool);
toolResults.push({
type: tool.type,
params: tool,
result: result
});
toolStats.push({
type: tool.type,
duration_ms: Date.now() - toolStartTime,
status: 'success'
});
} catch (e) {
console.error(`Orchestrator: Tool ${tool.type} failed`, e);
toolResults.push({
type: tool.type,
params: tool,
error: e.message
});
toolStats.push({
type: tool.type,
duration_ms: Date.now() - toolStartTime,
status: 'error',
error: e.message
});
}
}
}
// 2. NOP Mode Check (Implicit or Explicit)
if (!model || model === 'nop') {
log('Orchestrator', 'NOP mode detected. Returning tool results.');
send({
type: 'job_complete',
jobId: id,
result: JSON.stringify(toolResults),
stats: {
total_duration_ms: Date.now() - jobStartTime,
tools: toolStats
}
});
return;
}
// 3. Validate Messages
if (!messages || !Array.isArray(messages)) {
throw new Error("You need to provide the messages field when you want a model to execute it");
}
// 4. Context Injection
let contextString = '';
if (toolResults.length > 0) {
contextString = toolResults.map((tr, i) => {
if (tr.error) return `${i + 1}. ${tr.type} failed: ${tr.error}`;
return `${i + 1}. ${tr.type} call for ${JSON.stringify(tr.params)} answered:\n${tr.result}`;
}).join('\n\n---\n\n');
}
const injectedMessages = messages.map(msg => {
if (typeof msg.content === 'string') {
return { ...msg, content: msg.content.replace('${BROWSER_TOOL_CONTEXT}', contextString) };
}
return msg;
});
// 5. Execute Model
log('Orchestrator', `Executing model: ${model}`);
const modelStartTime = Date.now();
const modelExecutor = Registry.getModelExecutor(model);
const result = await modelExecutor.execute({ ...job, messages: injectedMessages });
const modelDuration = Date.now() - modelStartTime;
send({
type: 'job_complete',
jobId: id,
result,
stats: {
total_duration_ms: Date.now() - jobStartTime,
model_duration_ms: modelDuration,
tools: toolStats
}
});
} catch (error) {
console.error('Orchestrator: Job failed', error);
// Notify Keep-Alive tab
notifyKeepAlive('Job Failed', `Model: ${model || 'nop'}. Error: ${error.message}`);
// For now, just send error back
send({ type: 'job_error', jobId: id, error: error.message });
}
}
async function notifyKeepAlive(title, message) {
const url = 'http://localhost:31337/keep-alive';
const tabs = await chrome.tabs.query({ url });
if (tabs.length > 0) {
chrome.tabs.sendMessage(tabs[0].id, {
action: 'show_notification',
title,
message
});
// Optional: Focus the keep-alive tab
// chrome.tabs.update(tabs[0].id, { active: true });
}
}
function send(msg) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}
// --- Helper Functions (Placeholder for now, will import from Registry) ---
// These functions are now handled by Registry.getToolExecutor and Registry.getModelExecutor
// and the logic is integrated directly into handleJob.
// The original helper functions are no longer needed in this file.
// async function executeBrowserTools(tools) { ... }
// function formatToolResults(results) { ... }
// function injectContext(messages, context) { ... }
// async function executeModel(modelName, messages) { ... }
// Start connection
connect();
ensureKeepAliveTab();
// Keep-alive listener
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'heartbeat') {
console.log('Orchestrator: Heartbeat received');
sendResponse({ status: 'alive' });
} else if (request.action === 'log_entry') {
const { tag, message, level, timestamp } = request;
const source = sender.tab ? `tab-${sender.tab.id}` : 'extension';
// Forward to server
send({
type: 'remote_log',
log: {
source,
tag,
message,
level,
timestamp
}
});
}
});
// Helper for Orchestrator logging
function log(tag, ...args) {
const message = args.map(a => (typeof a === 'object') ? JSON.stringify(a) : String(a)).join(' ');
console.log(`[${tag}]`, ...args);
send({
type: 'remote_log',
log: {
source: 'orchestrator',
tag,
message,
level: 'info',
timestamp: Date.now()
}
});
}
async function ensureKeepAliveTab() {
const url = 'http://localhost:31337/keep-alive';
const tabs = await chrome.tabs.query({ url });
if (tabs.length === 0) {
console.log('Orchestrator: Opening keep-alive tab');
await chrome.tabs.create({ url, active: false, pinned: true });
} else {
console.log('Orchestrator: Keep-alive tab already exists');
}
}Example code for "executors" chosen by orchestrator for work...:
// Background service worker for handling API requests
// This script manages communication between content scripts and external APIs
// Import configuration
importScripts('../config/config.js');
importScripts('../executors/registry.js');
importScripts('../executors/base.js');
importScripts('../executors/prompt-decorator.js');
importScripts('../executors/model/chatgpt.js');
importScripts('../executors/tool/google.js');
importScripts('../executors/tool/linkedin.js');
importScripts('../executors/tool/maps-google.js');
importScripts('../executors/tool/wise-iban.js');
importScripts('orchestrator.js');
console.log('Profile To minless: Background service worker loaded');
/**
* Main message listener for handling requests from content scripts
* Processes profile data and forwards it to APIs
*/
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('Background received message:', request.action);
if (request.action === "sendToApi") {
// Handle both profile data and activity data
const dataToProcess = request.profileData || request.activityData;
if (dataToProcess) {
processApiRequest(dataToProcess, sendResponse);
} else {
sendResponse({
success: false,
message: 'No profile or activity data provided'
});
}
return true; // Keep message channel open for async response
}
});
/**
* Processes profile data and sends it to the configured API
* Handles test mode detection and error scenarios
* @param {Object} profileData - The extracted LinkedIn profile data
* @param {Function} sendResponse - Callback function to send response back to content script
*/
async function processApiRequest(profileData, sendResponse) {
try {
console.log('Background: Processing profile data for API:', profileData);
// Use webhook URL from configuration
const ApiUrl = CONFIG.API_URL;
// Handle test mode - don't send actual webhook request
if (profileData.list === "Test") {
console.log('Background: Test mode detected - skipping API call');
console.log('Test profile data:', profileData);
sendResponse({
success: true,
message: 'Test mode: Profile data processed successfully without sending to Zapier'
});
return;
}
// Send profile data to API
// Dont provide headers as CORS is not allowed
const webhookResponse = await fetch(ApiUrl, {
method: 'POST',
body: JSON.stringify(profileData)
});
// Handle successful webhook response
if (webhookResponse.ok) {
console.log('Background: Profile data successfully sent to API');
const responseData = await webhookResponse.json();
console.log('Background: API response:', responseData);
sendResponse({
success: true,
message: 'Profile data successfully sent to API'
});
} else {
// Handle HTTP error responses
console.error('Background: API request failed with status:', webhookResponse.status);
sendResponse({
success: false,
message: `Failed to send data to API (HTTP ${webhookResponse.status})`
});
}
} catch (error) {
// Handle network errors and other exceptions
console.error('Background: Error sending profile data to API:', error);
sendResponse({
success: false,
message: 'Network error when sending data to API: ' + error.message
});
}
} BaseExecutor:
self.BaseExecutor = class BaseExecutor {
constructor(domain) {
this.domain = domain;
}
async execute(data) {
throw new Error('Not implemented');
}
async createTab(url) {
const targetUrl = url || `https://${this.domain}`;
const tab = await chrome.tabs.create({ url: targetUrl });
await this.waitForTabLoad(tab.id);
return tab;
}
async closeTab(tabId) {
try {
await chrome.tabs.remove(tabId);
} catch (e) {
console.warn('Failed to close tab', tabId, e);
}
}
async waitForTabLoad(tabId) {
return new Promise(async (resolve) => {
const tab = await chrome.tabs.get(tabId);
if (tab.status === 'complete') {
return resolve(tab);
}
const listener = (tid, changeInfo, tab) => {
if (tid === tabId && changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve(tab);
}
};
chrome.tabs.onUpdated.addListener(listener);
});
}
async sendMessageToTab(tabId, action, data) {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tabId, { action, data }, (response) => {
if (chrome.runtime.lastError) {
return reject(new Error(chrome.runtime.lastError.message));
}
if (response && response.success) {
resolve(response.result || response.data);
} else {
reject(new Error(response ? response.error : 'Unknown error from content script'));
}
});
});
}
// Helper to be used INSIDE the injected script
static async waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element ${selector} not found`));
}, timeout);
});
}
}Executor Tool registry:
// Registry for Executors
self.Registry = {
models: new Map(),
tools: new Map(),
registerModel(name, executorClass) {
console.log(`Registry: Registering model ${name}`);
this.models.set(name, executorClass);
},
registerTool(name, cls) {
console.log(`Registry: Registering tool ${name}`);
this.tools.set(name, cls);
},
getModel(name) {
const cls = this.models.get(name);
console.log(`Registry: Getting model ${name} -> ${cls ? 'Found' : 'Not Found'}`);
return cls;
},
getTool(name) {
const cls = this.tools.get(name);
console.log(`Registry: Getting tool ${name} -> ${cls ? 'Found' : 'Not Found'}`);
return cls;
},
getModelExecutor(name) {
const Cls = this.getModel(name);
if (!Cls) throw new Error(`Model executor not found for: ${name}`);
return new Cls();
},
getToolExecutor(name) {
const Cls = this.getTool(name);
if (!Cls) throw new Error(`Tool executor not found for: ${name}`);
return new Cls();
}
};
Google Search Executor:
self.GoogleExecutor = class GoogleExecutor extends self.BaseExecutor {
constructor() {
super('google.com');
}
async execute(toolData) {
const query = toolData.query;
const topK = toolData.topK || 3;
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
const tab = await this.createTab(searchUrl);
try {
return await this.sendMessageToTab(tab.id, 'EXECUTE_GOOGLE', { query, topK });
} finally {
await this.closeTab(tab.id);
}
}
}
Registry.registerTool('google.com', GoogleExecutor);Actual Google Search automation impl (living in content script - injected!):
// Google Content Script
const log = (tag, ...args) => window.RemoteLogger ? window.RemoteLogger.log(tag, ...args) : console.log(`[${tag}]`, ...args);
const logError = (tag, ...args) => window.RemoteLogger ? window.RemoteLogger.error(tag, ...args) : console.error(`[${tag}]`, ...args);
log('Google', 'Content Script Loaded');
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'EXECUTE_GOOGLE') {
log('Google', 'Executing request', request.data);
executeGoogle(request.data, sendResponse);
return true; // Async response
}
});
async function executeGoogle(data, sendResponse) {
try {
const { topK } = data;
// --- Wait for Content ---
const waitForSelector = (selectors, timeout = 10000) => {
return new Promise((resolve) => {
const check = () => {
for (let s of selectors) {
const el = document.querySelector(s);
if (el) return resolve(el);
}
};
const observer = new MutationObserver(() => {
check();
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial check
check();
setTimeout(() => {
observer.disconnect();
resolve(null); // Timeout
}, timeout);
});
};
log('Google', 'Waiting for results...');
// Wait for either answer box or results
await waitForSelector(['[data-spe="true"]', '[data-subtree="aimc"]', '[data-rpos]'], 10000);
log('Google', 'Results found (or timeout)');
// --- Extraction ---
let resultMarkdown = '';
// 1. Try Answer Box / Knowledge Panel
const answerBox = document.querySelector('[data-spe="true"]') || document.querySelector('[data-subtree="aimc"]');
if (answerBox) {
log('Google', 'Found Answer Box');
resultMarkdown += "### Answer Box\n";
resultMarkdown += window.htmlToMarkdown(answerBox);
resultMarkdown += "\n\n---\n\n";
}
// 2. Top K Results
const results = Array.from(document.querySelectorAll('[data-rpos]'));
log('Google', `Found ${results.length} organic results`);
const k = topK || 3;
const topResults = results.slice(0, k);
if (topResults.length > 0) {
resultMarkdown += `### Top ${topResults.length} Results\n`;
for (let i = 0; i < topResults.length; i++) {
resultMarkdown += `\n#### Result ${i + 1}\n`;
resultMarkdown += window.htmlToMarkdown(topResults[i]);
resultMarkdown += "\n";
}
}
if (!resultMarkdown) {
log('Google', 'No markdown extracted');
sendResponse({ success: true, result: "No results found." });
} else {
log('Google', 'Extraction complete');
sendResponse({ success: true, result: resultMarkdown });
}
} catch (error) {
logError('Google', 'Error:', error);
sendResponse({ success: false, error: error.message });
}
}keep-alive script:
// Keep-alive content script
console.log('Keep-Alive: Content script loaded');
setInterval(() => {
if (!chrome.runtime?.id) {
console.log('Keep-Alive: Extension context invalidated. Reloading...');
window.location.reload();
return;
}
console.log('Keep-Alive: Sending heartbeat');
try {
chrome.runtime.sendMessage({ action: 'heartbeat' }, (response) => {
if (chrome.runtime.lastError) {
console.warn('Keep-Alive: Error sending heartbeat', chrome.runtime.lastError);
if (chrome.runtime.lastError.message.includes('Extension context invalidated')) {
console.log('Keep-Alive: Reloading due to invalidation');
window.location.reload();
}
} else {
console.log('Keep-Alive: Heartbeat acknowledged', response);
}
});
} catch (e) {
console.error('Keep-Alive: Exception sending heartbeat', e);
if (e.message.includes('Extension context invalidated')) {
window.location.reload();
}
}
}, 25000); // 25 seconds
// Request notification permission
if (Notification.permission !== 'granted') {
Notification.requestPermission();
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'show_notification') {
console.log('Keep-Alive: Showing notification', request);
// Show Web Notification
if (Notification.permission === 'granted') {
new Notification(request.title, {
body: request.message,
icon: '/assets/icons/icon128.png' // Assuming this exists or use default
});
}
// Also show in DOM
const div = document.createElement('div');
div.style.cssText = 'position: fixed; top: 10px; right: 10px; background: red; color: white; padding: 10px; z-index: 9999; border-radius: 5px;';
div.innerText = `${request.title}: ${request.message}`;
document.body.appendChild(div);
setTimeout(() => div.remove(), 5000);
}
});Finally, some code for rendering toolbar icon and popup:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhisperLiveKit</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="header-container">
<div class="settings-container">
<div class="buttons-container">
<button id="recordButton">
<div class="shape-container">
<div class="shape"></div>
</div>
<div class="recording-info">
<div class="wave-container">
<canvas id="waveCanvas"></canvas>
</div>
<div class="timer">00:00</div>
</div>
</button>
<button id="settingsToggle" class="settings-toggle" title="Show/hide settings">
<img src="icons/settings.svg" alt="Settings" />
</button>
</div>
<div class="settings">
<div class="field">
<label for="websocketInput">Websocket URL</label>
<input id="websocketInput" type="text" placeholder="ws://host:port/asr" />
</div>
<div class="field">
<label id="microphoneSelectLabel" for="microphoneSelect">Select Microphone</label>
<select id="microphoneSelect">
<option value="">Default Microphone</option>
</select>
</div>
<div class="theme-selector-container">
<div class="segmented" role="radiogroup" aria-label="Theme selector">
<input type="radio" id="theme-system" name="theme" value="system" />
<label for="theme-system" title="System">
<img src="icons/system_mode.svg" alt="" />
<span>System</span>
</label>
<input type="radio" id="theme-light" name="theme" value="light" />
<label for="theme-light" title="Light">
<img src="icons/light_mode.svg" alt="" />
<span>Light</span>
</label>
<input type="radio" id="theme-dark" name="theme" value="dark" />
<label for="theme-dark" title="Dark">
<img src="icons/dark_mode.svg" alt="" />
<span>Dark</span>
<script src="popup.js"></script>
</body>
</html>const isExtension = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL;
if (isExtension) {
document.documentElement.classList.add('is-extension');
}
const isWebContext = !isExtension;
let isRecording = false;
let websocket = null;
let orchestratorWs = null; // Connection to Orchestrator (31337)
let recorder = null;
let chunkDuration = 100;
let websocketUrl = "ws://localhost:31338/asr"; // Default to 31338
const orchestratorUrl = "ws://localhost:31337";
let userClosing = false;
let wakeLock = null;
let startTime = null;
let timerInterval = null;
let audioContext = null;
let analyser = null;
let microphone = null;
let workletNode = null;
let recorderWorker = null;
let waveCanvas = document.getElementById("waveCanvas");
let waveCtx = waveCanvas.getContext("2d");
let animationFrame = null;
let waitingForStop = false;
let lastReceivedData = null;
let lastSignature = null;
let availableMicrophones = [];
let selectedMicrophoneId = null;
let serverUseAudioWorklet = null;
let configReadyResolve;
const configReady = new Promise((r) => (configReadyResolve = r));
let outputAudioContext = null;
let audioSource = null;
waveCanvas.width = 60 * (window.devicePixelRatio || 1);
waveCanvas.height = 30 * (window.devicePixelRatio || 1);
waveCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
const statusText = document.getElementById("status");
const recordButton = document.getElementById("recordButton");
const chunkSelector = document.getElementById("chunkSelector");
const websocketInput = document.getElementById("websocketInput");
const websocketDefaultSpan = document.getElementById("wsDefaultUrl");
const linesTranscriptDiv = document.getElementById("linesTranscript");
const timerElement = document.querySelector(".timer");
const themeRadios = document.querySelectorAll('input[name="theme"]');
const microphoneSelect = document.getElementById("microphoneSelect");
const settingsToggle = document.getElementById("settingsToggle");
const settingsDiv = document.querySelector(".settings");
const translationIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12px" viewBox="0 -960 960 960" width="12px" fill="#5f6368"><path d="m603-202-34 97q-4 11-14 18t-22 7q-20 0-32.5-16.5T496-133l152-402q5-11 15-18t22-7h30q12 0 22 7t15 18l152 403q8 19-4 35.5T868-80q-13 0-22.5-7T831-106l-34-96H603ZM362-401 188-228q-11 11-27.5 11.5T132-228q-11-11-11-28t11-28l174-174q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H80q-17 0-28.5-11.5T40-760q0-17 11.5-28.5T80-800h240v-40q0-17 11.5-28.5T360-880q17 0 28.5 11.5T400-840v40h240q17 0 28.5 11.5T680-760q0 17-11.5 28.5T640-720h-76q-21 72-63 148t-83 116l96 98-30 82-122-125Zm266 129h144l-72-204-72 204Z"/></svg>`
const silenceIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="vertical-align: text-bottom;" height="14px" viewBox="0 -960 960 960" width="14px" fill="#5f6368"><path d="M514-556 320-752q9-3 19-5.5t21-2.5q66 0 113 47t47 113q0 11-1.5 22t-4.5 22ZM40-200v-32q0-33 17-62t47-44q51-26 115-44t141-18q26 0 49.5 2.5T456-392l-56-54q-9 3-19 4.5t-21 1.5q-66 0-113-47t-47-113q0-11 1.5-21t4.5-19L84-764q-11-11-11-28t11-28q12-12 28.5-12t27.5 12l675 685q11 11 11.5 27.5T816-80q-11 13-28 12.5T759-80L641-200h39q0 33-23.5 56.5T600-120H120q-33 0-56.5-23.5T40-200Zm80 0h480v-32q0-14-4.5-19.5T580-266q-36-18-92.5-36T360-320q-71 0-127.5 18T140-266q-9 5-14.5 14t-5.5 20v32Zm240 0Zm560-400q0 69-24.5 131.5T829-355q-12 14-30 15t-32-13q-13-13-12-31t12-33q30-38 46.5-85t16.5-98q0-51-16.5-97T767-781q-12-15-12.5-33t12.5-32q13-14 31.5-13.5T829-845q42 51 66.5 113.5T920-600Zm-182 0q0 32-10 61.5T700-484q-11 15-29.5 15.5T638-482q-13-13-13.5-31.5T633-549q6-11 9.5-24t3.5-27q0-14-3.5-27t-9.5-25q-9-17-8.5-35t13.5-31q14-14 32.5-13.5T700-716q18 25 28 54.5t10 61.5Z"/></svg>`;
const languageIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="12" viewBox="0 -960 960 960" width="12" fill="#5f6368"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>`
const speakerIcon = `<svg xmlns="http://www.w3.org/2000/svg" height="16px" style="vertical-align: text-bottom;" viewBox="0 -960 960 960" width="16px" fill="#5f6368"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-240v-32q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v32q0 33-23.5 56.5T720-160H240q-33 0-56.5-23.5T160-240Zm80 0h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>`;
function getWaveStroke() {
const styles = getComputedStyle(document.documentElement);
const v = styles.getPropertyValue("--wave-stroke").trim();
return v || "#000";
}
let waveStroke = getWaveStroke();
function updateWaveStroke() {
waveStroke = getWaveStroke();
}
function applyTheme(pref) {
if (pref === "light") {
document.documentElement.setAttribute("data-theme", "light");
} else if (pref === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme");
}
updateWaveStroke();
}
// Persisted theme preference
const savedThemePref = localStorage.getItem("themePreference") || "system";
applyTheme(savedThemePref);
if (themeRadios.length) {
themeRadios.forEach((r) => {
r.checked = r.value === savedThemePref;
r.addEventListener("change", () => {
if (r.checked) {
localStorage.setItem("themePreference", r.value);
applyTheme(r.value);
}
});
});
}
// React to OS theme changes when in "system" mode
const darkMq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
const handleOsThemeChange = () => {
const pref = localStorage.getItem("themePreference") || "system";
if (pref === "system") updateWaveStroke();
};
if (darkMq && darkMq.addEventListener) {
darkMq.addEventListener("change", handleOsThemeChange);
} else if (darkMq && darkMq.addListener) {
// deprecated, but included for Safari compatibility
darkMq.addListener(handleOsThemeChange);
}
const requestPermissionBtn = document.getElementById("requestPermissionBtn");
async function enumerateMicrophones() {
try {
// Check if we already have permission
const permissions = await navigator.permissions.query({ name: 'microphone' });
if (permissions.state === 'denied') {
statusText.textContent = "Microphone permission denied. Please enable it in browser settings.";
return;
}
// If 'prompt' or 'granted', try to get stream to enumerate labels
// Note: getUserMedia might fail if no user gesture, but usually works in popup if previously granted
// or if it's the first time and we are in a valid context.
// However, to be safe, if it fails, we show the button.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
const devices = await navigator.mediaDevices.enumerateDevices();
availableMicrophones = devices.filter(device => device.kind === 'audioinput');
populateMicrophoneSelect();
console.log(`Found ${availableMicrophones.length} microphone(s)`);
if (requestPermissionBtn) requestPermissionBtn.style.display = 'none';
statusText.textContent = "Ready to record.";
} catch (error) {
console.error('Error enumerating microphones:', error);
statusText.textContent = "Microphone access required.";
if (requestPermissionBtn) {
requestPermissionBtn.style.display = 'inline-block';
requestPermissionBtn.onclick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
await enumerateMicrophones();
} catch (e) {
console.error("Permission request failed:", e);
statusText.textContent = "Permission denied or failed.";
}
};
}
}
}
function populateMicrophoneSelect() {
if (!microphoneSelect) return;
microphoneSelect.innerHTML = '<option value="">Default Microphone</option>';
availableMicrophones.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `Microphone ${index + 1}`;
microphoneSelect.appendChild(option);
});
const savedMicId = localStorage.getItem('selectedMicrophone');
if (savedMicId && availableMicrophones.some(mic => mic.deviceId === savedMicId)) {
microphoneSelect.value = savedMicId;
selectedMicrophoneId = savedMicId;
}
}
function handleMicrophoneChange() {
selectedMicrophoneId = microphoneSelect.value || null;
localStorage.setItem('selectedMicrophone', selectedMicrophoneId || '');
const selectedDevice = availableMicrophones.find(mic => mic.deviceId === selectedMicrophoneId);
const deviceName = selectedDevice ? selectedDevice.label : 'Default Microphone';
console.log(`Selected microphone: ${deviceName}`);
statusText.textContent = `Microphone changed to: ${deviceName}`;
if (isRecording) {
statusText.textContent = "Switching microphone... Please wait.";
stopRecording().then(() => {
setTimeout(() => {
toggleRecording();
}, 1000);
});
}
}
// Helpers
function fmt1(x) {
const n = Number(x);
return Number.isFinite(n) ? n.toFixed(1) : x;
}
// Populate default caption and input
if (websocketDefaultSpan) websocketDefaultSpan.textContent = websocketUrl;
websocketInput.value = websocketUrl;
// Optional chunk selector (guard for presence)
if (chunkSelector) {
chunkSelector.addEventListener("change", () => {
chunkDuration = parseInt(chunkSelector.value);
});
}
// WebSocket input change handling
websocketInput.addEventListener("change", () => {
const urlValue = websocketInput.value.trim();
if (!urlValue.startsWith("ws://") && !urlValue.startsWith("wss://")) {
statusText.textContent = "Invalid WebSocket URL (must start with ws:// or wss://)";
return;
}
websocketUrl = urlValue;
statusText.textContent = "WebSocket URL updated. Ready to connect.";
});
function setupWebSocket() {
return new Promise((resolve, reject) => {
try {
websocket = new WebSocket(websocketUrl);
} catch (error) {
statusText.textContent = "Invalid WebSocket URL. Please check and try again.";
reject(error);
return;
}
websocket.onopen = () => {
statusText.textContent = "Connected to ASR server.";
resolve();
};
websocket.onclose = () => {
if (userClosing) {
if (waitingForStop) {
statusText.textContent = "Processing finalized or connection closed.";
if (lastReceivedData) {
renderLinesWithBuffer(
lastReceivedData.lines || [],
lastReceivedData.buffer_diarization || "",
lastReceivedData.buffer_transcription || "",
lastReceivedData.buffer_translation || "",
0,
0,
true
);
}
}
} else {
statusText.textContent = "Disconnected from the WebSocket server. (Check logs if model is loading.)";
if (isRecording) {
stopRecording();
}
}
isRecording = false;
waitingForStop = false;
userClosing = false;
lastReceivedData = null;
websocket = null;
updateUI();
};
websocket.onerror = () => {
statusText.textContent = "Error connecting to WebSocket.";
reject(new Error("Error connecting to WebSocket"));
};
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
// Forward to Orchestrator
if (orchestratorWs && orchestratorWs.readyState === WebSocket.OPEN) {
orchestratorWs.send(JSON.stringify({
type: 'transcription',
data: data
}));
}
if (data.type === "config") {
serverUseAudioWorklet = !!data.useAudioWorklet;
statusText.textContent = serverUseAudioWorklet
? "Connected. Using AudioWorklet (PCM)."
: "Connected. Using MediaRecorder (WebM).";
if (configReadyResolve) configReadyResolve();
return;
}
if (data.type === "ready_to_stop") {
console.log("Ready to stop received, finalizing display and closing WebSocket.");
waitingForStop = false;
if (lastReceivedData) {
renderLinesWithBuffer(
lastReceivedData.lines || [],
lastReceivedData.buffer_diarization || "",
lastReceivedData.buffer_transcription || "",
lastReceivedData.buffer_translation || "",
0,
0,
true
);
}
statusText.textContent = "Finished processing audio! Ready to record again.";
recordButton.disabled = false;
if (websocket) {
websocket.close();
}
return;
}
lastReceivedData = data;
const {
lines = [],
buffer_transcription = "",
buffer_diarization = "",
buffer_translation = "",
remaining_time_transcription = 0,
remaining_time_diarization = 0,
status = "active_transcription",
} = data;
renderLinesWithBuffer(
lines,
buffer_diarization,
buffer_transcription,
buffer_translation,
remaining_time_diarization,
remaining_time_transcription,
false,
status
);
};
});
}
function setupOrchestratorWebSocket() {
try {
orchestratorWs = new WebSocket(orchestratorUrl);
orchestratorWs.onopen = () => {
console.log("Connected to Orchestrator.");
};
orchestratorWs.onclose = () => {
console.log("Disconnected from Orchestrator.");
orchestratorWs = null;
};
orchestratorWs.onerror = (err) => {
console.error("Orchestrator WS Error:", err);
};
} catch (e) {
console.error("Failed to connect to Orchestrator:", e);
}
}
function renderLinesWithBuffer(
lines,
buffer_diarization,
buffer_transcription,
buffer_translation,
remaining_time_diarization,
remaining_time_transcription,
isFinalizing = false,
current_status = "active_transcription"
) {
if (current_status === "no_audio_detected") {
linesTranscriptDiv.innerHTML =
"<p style='text-align: center; color: var(--muted); margin-top: 20px;'><em>No audio detected...</em></p>";
return;
}
const showLoading = !isFinalizing && (lines || []).some((it) => it.speaker == 0);
const showTransLag = !isFinalizing && remaining_time_transcription > 0;
const showDiaLag = !isFinalizing && !!buffer_diarization && remaining_time_diarization > 0;
const signature = JSON.stringify({
lines: (lines || []).map((it) => ({ speaker: it.speaker, text: it.text, start: it.start, end: it.end, detected_language: it.detected_language })),
buffer_transcription: buffer_transcription || "",
buffer_diarization: buffer_diarization || "",
buffer_translation: buffer_translation,
status: current_status,
showLoading,
showTransLag,
showDiaLag,
isFinalizing: !!isFinalizing,
});
if (lastSignature === signature) {
const t = document.querySelector(".lag-transcription-value");
if (t) t.textContent = fmt1(remaining_time_transcription);
const d = document.querySelector(".lag-diarization-value");
if (d) d.textContent = fmt1(remaining_time_diarization);
const ld = document.querySelector(".loading-diarization-value");
if (ld) ld.textContent = fmt1(remaining_time_diarization);
return;
}
lastSignature = signature;
const linesHtml = (lines || [])
.map((item, idx) => {
let timeInfo = "";
if (item.start !== undefined && item.end !== undefined) {
timeInfo = ` ${item.start} - ${item.end}`;
}
let speakerLabel = "";
if (item.speaker === -2) {
speakerLabel = `<span class="silence">${silenceIcon}<span id='timeInfo'>${timeInfo}</span></span>`;
} else if (item.speaker == 0 && !isFinalizing) {
speakerLabel = `<span class='loading'><span class="spinner"></span><span id='timeInfo'><span class="loading-diarization-value">${fmt1(
remaining_time_diarization
)}</span> second(s) of audio are undergoing diarization</span></span>`;
} else if (item.speaker !== 0) {
const speakerNum = `<span class="speaker-badge">${item.speaker}</span>`;
speakerLabel = `<span id="speaker">${speakerIcon}${speakerNum}<span id='timeInfo'>${timeInfo}</span></span>`;
if (item.detected_language) {
speakerLabel += `<span class="label_language">${languageIcon}<span>${item.detected_language}</span></span>`;
}
}
let currentLineText = item.text || "";
if (idx === lines.length - 1) {
if (!isFinalizing && item.speaker !== -2) {
speakerLabel += `<span class="label_transcription"><span class="spinner"></span>Transcription lag <span id='timeInfo'><span class="lag-transcription-value">${fmt1(
remaining_time_transcription
)}</span>s</span></span>`;
if (buffer_diarization && remaining_time_diarization) {
speakerLabel += `<span class="label_diarization"><span class="spinner"></span>Diarization lag<span id='timeInfo'><span class="lag-diarization-value">${fmt1(
remaining_time_diarization
)}</span>s</span></span>`;
}
}
if (buffer_diarization) {
if (isFinalizing) {
currentLineText +=
(currentLineText.length > 0 && buffer_diarization.trim().length > 0 ? " " : "") + buffer_diarization.trim();
} else {
currentLineText += `<span class="buffer_diarization">${buffer_diarization}</span>`;
}
}
if (buffer_transcription) {
if (isFinalizing) {
currentLineText +=
(currentLineText.length > 0 && buffer_transcription.trim().length > 0 ? " " : "") +
buffer_transcription.trim();
} else {
currentLineText += `<span class="buffer_transcription">${buffer_transcription}</span>`;
}
}
}
let translationContent = "";
if (item.translation) {
translationContent += item.translation.trim();
}
if (idx === lines.length - 1 && buffer_translation) {
const bufferPiece = isFinalizing
? buffer_translation
: `<span class="buffer_translation">${buffer_translation}</span>`;
translationContent += translationContent ? `${bufferPiece}` : bufferPiece;
}
if (translationContent.trim().length > 0) {
currentLineText += `
<div>
<div class="label_translation">
${translationIcon}
<span class="translation_text">${translationContent}</span>
</div>
</div>`;
}
return currentLineText.trim().length > 0 || speakerLabel.length > 0
? `<p>${speakerLabel}<br/><div class='textcontent'>${currentLineText}</div></p>`
: `<p>${speakerLabel}<br/></p>`;
})
.join("");
linesTranscriptDiv.innerHTML = linesHtml;
const transcriptContainer = document.querySelector('.transcript-container');
if (transcriptContainer) {
transcriptContainer.scrollTo({ top: transcriptContainer.scrollHeight, behavior: "smooth" });
}
}
function updateTimer() {
if (!startTime) return;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, "0");
const seconds = (elapsed % 60).toString().padStart(2, "0");
timerElement.textContent = `${minutes}:${seconds}`;
}
function drawWaveform() {
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
waveCtx.clearRect(
0,
0,
waveCanvas.width / (window.devicePixelRatio || 1),
waveCanvas.height / (window.devicePixelRatio || 1)
);
waveCtx.lineWidth = 1;
waveCtx.strokeStyle = waveStroke;
waveCtx.beginPath();
const sliceWidth = (waveCanvas.width / (window.devicePixelRatio || 1)) / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * (waveCanvas.height / (window.devicePixelRatio || 1))) / 2;
if (i === 0) {
waveCtx.moveTo(x, y);
} else {
waveCtx.lineTo(x, y);
}
x += sliceWidth;
}
waveCtx.lineTo(
waveCanvas.width / (window.devicePixelRatio || 1),
(waveCanvas.height / (window.devicePixelRatio || 1)) / 2
);
waveCtx.stroke();
animationFrame = requestAnimationFrame(drawWaveform);
}
async function startRecording() {
try {
try {
wakeLock = await navigator.wakeLock.request("screen");
} catch (err) {
console.log("Error acquiring wake lock.");
}
let stream;
// chromium extension. in the future, both chrome page audio and mic will be used
if (isExtension) {
try {
stream = await new Promise((resolve, reject) => {
chrome.tabCapture.capture({ audio: true }, (s) => {
if (s) {
resolve(s);
} else {
reject(new Error('Tab capture failed or not available'));
}
});
});
try {
outputAudioContext = new (window.AudioContext || window.webkitAudioContext)();
audioSource = outputAudioContext.createMediaStreamSource(stream);
audioSource.connect(outputAudioContext.destination);
} catch (audioError) {
console.warn('could not preserve system audio:', audioError);
}
statusText.textContent = "Using tab audio capture.";
} catch (tabError) {
console.log('Tab capture not available, falling back to microphone', tabError);
const audioConstraints = selectedMicrophoneId
? { audio: { deviceId: { exact: selectedMicrophoneId } } }
: { audio: true };
stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
statusText.textContent = "Using microphone audio.";
}
} else if (isWebContext) {
const audioConstraints = selectedMicrophoneId
? { audio: { deviceId: { exact: selectedMicrophoneId } } }
: { audio: true };
stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
}
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
if (serverUseAudioWorklet) {
if (!audioContext.audioWorklet) {
throw new Error("AudioWorklet is not supported in this browser");
}
// NOTE: pcm_worklet.js is missing, so this might fail if serverUseAudioWorklet is true.
// Assuming user has files or will provide them.
await audioContext.audioWorklet.addModule("web/pcm_worklet.js");
workletNode = new AudioWorkletNode(audioContext, "pcm-forwarder", { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
microphone.connect(workletNode);
recorderWorker = new Worker("web/recorder_worker.js");
recorderWorker.postMessage({
command: "init",
config: {
sampleRate: audioContext.sampleRate,
},
});
recorderWorker.onmessage = (e) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(e.data.buffer);
}
};
workletNode.port.onmessage = (e) => {
const data = e.data;
const ab = data instanceof ArrayBuffer ? data : data.buffer;
recorderWorker.postMessage(
{
command: "record",
buffer: ab,
},
[ab]
);
};
} else {
try {
recorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
} catch (e) {
recorder = new MediaRecorder(stream);
}
recorder.ondataavailable = (e) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
if (e.data && e.data.size > 0) {
websocket.send(e.data);
}
}
};
recorder.start(chunkDuration);
}
startTime = Date.now();
timerInterval = setInterval(updateTimer, 1000);
drawWaveform();
isRecording = true;
updateUI();
} catch (err) {
if (window.location.hostname === "0.0.0.0") {
statusText.textContent =
"Error accessing microphone. Browsers may block microphone access on 0.0.0.0. Try using localhost:8000 instead.";
} else {
statusText.textContent = "Error accessing microphone. Please allow microphone access.";
}
console.error(err);
}
}
async function stopRecording() {
if (wakeLock) {
try {
await wakeLock.release();
} catch (e) {
// ignore
}
wakeLock = null;
}
userClosing = true;
waitingForStop = true;
if (websocket && websocket.readyState === WebSocket.OPEN) {
const emptyBlob = new Blob([], { type: "audio/webm" });
websocket.send(emptyBlob);
statusText.textContent = "Recording stopped. Processing final audio...";
}
if (recorder) {
try {
recorder.stop();
} catch (e) {
}
recorder = null;
}
if (recorderWorker) {
recorderWorker.terminate();
recorderWorker = null;
}
if (workletNode) {
try {
workletNode.port.onmessage = null;
} catch (e) { }
try {
workletNode.disconnect();
} catch (e) { }
workletNode = null;
}
if (microphone) {
microphone.disconnect();
microphone = null;
}
if (analyser) {
analyser = null;
}
if (audioContext && audioContext.state !== "closed") {
try {
await audioContext.close();
} catch (e) {
console.warn("Could not close audio context:", e);
}
audioContext = null;
}
if (audioSource) {
audioSource.disconnect();
audioSource = null;
}
if (outputAudioContext && outputAudioContext.state !== "closed") {
outputAudioContext.close()
outputAudioContext = null;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
timerElement.textContent = "00:00";
startTime = null;
isRecording = false;
updateUI();
}
async function toggleRecording() {
if (!isRecording) {
if (waitingForStop) {
console.log("Waiting for stop, early return");
return;
}
console.log("Connecting to WebSocket");
try {
// Ensure orchestrator is connected
if (!orchestratorWs || orchestratorWs.readyState !== WebSocket.OPEN) {
setupOrchestratorWebSocket();
}
if (websocket && websocket.readyState === WebSocket.OPEN) {
await configReady;
await startRecording();
} else {
await setupWebSocket();
await configReady;
await startRecording();
}
} catch (err) {
statusText.textContent = "Could not connect to WebSocket or access mic. Aborted.";
console.error(err);
}
} else {
console.log("Stopping recording");
stopRecording();
}
}
function updateUI() {
recordButton.classList.toggle("recording", isRecording);
recordButton.disabled = waitingForStop;
if (waitingForStop) {
if (statusText.textContent !== "Recording stopped. Processing final audio...") {
statusText.textContent = "Please wait for processing to complete...";
}
} else if (isRecording) {
statusText.textContent = "";
} else {
if (
statusText.textContent !== "Finished processing audio! Ready to record again." &&
statusText.textContent !== "Processing finalized or connection closed."
) {
statusText.textContent = "Click to start transcription";
}
}
if (!waitingForStop) {
recordButton.disabled = false;
}
}
recordButton.addEventListener("click", toggleRecording);
if (microphoneSelect) {
microphoneSelect.addEventListener("change", handleMicrophoneChange);
}
document.addEventListener('DOMContentLoaded', async () => {
try {
const permissions = await navigator.permissions.query({ name: 'microphone' });
if (permissions.state === 'granted') {
await enumerateMicrophones();
} else {
statusText.textContent = "Microphone access required.";
if (requestPermissionBtn) {
requestPermissionBtn.style.display = 'inline-block';
requestPermissionBtn.onclick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
await enumerateMicrophones();
} catch (e) {
console.error("Permission request failed:", e);
statusText.textContent = "Permission denied or failed.";
}
};
}
}
setupOrchestratorWebSocket(); // Connect to orchestrator on load
} catch (error) {
console.log("Could not check permissions on load:", error);
}
});
navigator.mediaDevices.addEventListener('devicechange', async () => {
console.log('Device change detected, re-enumerating microphones');
try {
await enumerateMicrophones();
} catch (error) {
console.log("Error re-enumerating microphones:", error);
}
});
settingsToggle.addEventListener("click", () => {
settingsDiv.classList.toggle("visible");
settingsToggle.classList.toggle("active");
});
if (isExtension) {
async function checkAndRequestPermissions() {
const micPermission = await navigator.permissions.query({
name: "microphone",
});
const permissionDisplay = document.getElementById("audioPermission");
if (permissionDisplay) {
permissionDisplay.innerText = `MICROPHONE: ${micPermission.state}`;
}
}
}Metadata
Metadata
Assignees
Labels
Projects
Status