This commit is contained in:
2026-02-13 19:24:20 +01:00
parent 56d56395ee
commit 9dc2dff84a
22 changed files with 5897 additions and 528 deletions

View File

@@ -9,6 +9,12 @@ let overlayHidden = false;
let analyserNode = null;
let meterSource = null;
let meterRaf = null;
let transcriptionRecorder = null;
let mixedTabStream = null;
let mixedMicStream = null;
let mixedOutputStream = null;
let lastTranscriptionErrorAt = 0;
let transcriptionWindowTimer = null;
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'startCapture') {
@@ -80,10 +86,7 @@ function startCapture(streamId) {
overlayListening = true;
ensureOverlay();
updateOverlayIndicator();
updateOverlay(
'response',
'Tab audio is captured, but speech recognition uses the microphone. Use mic or mixed mode if you want transcription.'
);
updateOverlay('response', 'Capturing tab audio and transcribing meeting audio...');
navigator.mediaDevices.getUserMedia({
audio: {
chromeMediaSource: 'tab',
@@ -93,9 +96,7 @@ function startCapture(streamId) {
mediaStream = stream;
audioContext = new AudioContext();
createAudioMeter(stream);
if (ensureSpeechRecognitionAvailable()) {
startRecognition();
}
startTranscriptionRecorder(stream, 'tab');
}).catch((error) => {
console.error('Error starting capture:', error);
let errorMessage = 'Failed to start audio capture. ';
@@ -147,18 +148,39 @@ function startMixedCapture(streamId) {
overlayListening = true;
ensureOverlay();
updateOverlayIndicator();
updateOverlay('response', 'Capturing mixed audio (tab + mic) and transcribing...');
navigator.mediaDevices.getUserMedia({
audio: {
chromeMediaSource: 'tab',
chromeMediaSourceId: streamId
}
}).then((stream) => {
mediaStream = stream;
}).then(async (tabStream) => {
mixedTabStream = tabStream;
audioContext = new AudioContext();
createAudioMeter(stream);
if (ensureSpeechRecognitionAvailable()) {
startRecognition();
try {
mixedMicStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
console.warn('Mixed mode mic unavailable, falling back to tab-only capture:', error);
mixedMicStream = null;
chrome.runtime.sendMessage({
action: 'updateAIResponse',
response: 'Mic permission denied in mixed mode. Continuing with tab audio only.'
});
}
const destination = audioContext.createMediaStreamDestination();
const tabSource = audioContext.createMediaStreamSource(tabStream);
tabSource.connect(destination);
if (mixedMicStream) {
const micSource = audioContext.createMediaStreamSource(mixedMicStream);
micSource.connect(destination);
}
mixedOutputStream = destination.stream;
mediaStream = mixedOutputStream;
createAudioMeter(mixedOutputStream);
startTranscriptionRecorder(mixedOutputStream, 'mixed');
}).catch((error) => {
console.error('Error starting mixed capture:', error);
chrome.runtime.sendMessage({action: 'updateAIResponse', response: 'Failed to start mixed capture.'});
@@ -235,20 +257,148 @@ function ensureSpeechRecognitionAvailable() {
return true;
}
function stopTranscriptionRecorder() {
if (transcriptionWindowTimer) {
clearTimeout(transcriptionWindowTimer);
transcriptionWindowTimer = null;
}
if (transcriptionRecorder && transcriptionRecorder.state !== 'inactive') {
try {
transcriptionRecorder.stop();
} catch (error) {
console.warn('Failed to stop transcription recorder:', error);
}
}
transcriptionRecorder = null;
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result || '';
const base64 = String(result).split(',')[1] || '';
resolve(base64);
};
reader.onerror = () => reject(new Error('Failed to read recorded audio chunk.'));
reader.readAsDataURL(blob);
});
}
function startTranscriptionRecorder(stream, mode) {
stopTranscriptionRecorder();
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const WINDOW_MS = 6000;
const sendBlobForTranscription = async (blob, currentMimeType) => {
if (!isCapturing || !blob || blob.size < 1024) return;
try {
const base64Audio = await blobToBase64(blob);
chrome.runtime.sendMessage(
{
action: 'transcribeAudioChunk',
audioBase64: base64Audio,
mimeType: currentMimeType || mimeType,
captureMode: mode
},
(response) => {
if (chrome.runtime.lastError) return;
if (!response || !response.success) {
const now = Date.now();
if (response && response.error && now - lastTranscriptionErrorAt > 6000) {
lastTranscriptionErrorAt = now;
chrome.runtime.sendMessage({ action: 'updateAIResponse', response: response.error });
updateOverlay('response', response.error);
}
return;
}
if (!response.transcript) return;
updateOverlay('transcript', response.transcript);
}
);
} catch (error) {
console.warn('Audio chunk transcription failed:', error);
}
};
const startWindow = () => {
if (!isCapturing) return;
const recorder = new MediaRecorder(stream, { mimeType });
transcriptionRecorder = recorder;
const chunks = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onerror = (event) => {
const message = `Audio recorder error: ${event.error ? event.error.message : 'unknown'}`;
chrome.runtime.sendMessage({ action: 'updateAIResponse', response: message });
updateOverlay('response', message);
};
recorder.onstop = async () => {
transcriptionRecorder = null;
if (!chunks.length) {
if (isCapturing) startWindow();
return;
}
const blob = new Blob(chunks, { type: recorder.mimeType || mimeType });
await sendBlobForTranscription(blob, recorder.mimeType || mimeType);
if (isCapturing) {
startWindow();
}
};
recorder.start();
transcriptionWindowTimer = setTimeout(() => {
transcriptionWindowTimer = null;
if (recorder.state !== 'inactive') {
recorder.stop();
}
}, WINDOW_MS);
};
startWindow();
}
function stopCapture() {
isCapturing = false;
overlayListening = false;
updateOverlayIndicator();
stopTranscriptionRecorder();
stopAudioMeter();
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (mixedTabStream) {
mixedTabStream.getTracks().forEach(track => track.stop());
mixedTabStream = null;
}
if (mixedMicStream) {
mixedMicStream.getTracks().forEach(track => track.stop());
mixedMicStream = null;
}
if (mixedOutputStream) {
mixedOutputStream.getTracks().forEach(track => track.stop());
mixedOutputStream = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (recognition) {
recognition.stop();
try {
recognition.stop();
} catch (error) {
console.warn('Failed to stop recognition:', error);
}
recognition = null;
}
}
@@ -385,7 +535,7 @@ function ensureOverlay() {
<div id="ai-interview-header">
<div id="ai-interview-title">
<span id="ai-interview-indicator"></span>
<span>AI Interview Assistant</span>
<span>AI Assistant</span>
</div>
<div id="ai-interview-controls">
<button class="ai-interview-btn" id="ai-interview-detach">Detach</button>