643 lines
20 KiB
JavaScript

/**
* Live Captions - Main Application
* Handles audio capture and WebSocket communication
*/
const App = {
// WebSocket connection
socket: null,
// Mic audio recording
mediaRecorder: null,
audioStream: null,
audioChunks: [],
isRecording: false,
recordingInterval: null,
// Mic caption stream
wordBuffer: [],
pendingWords: [],
wordAnimationTimer: null,
// Browser tab audio recording
desktopMediaRecorder: null,
desktopStream: null,
desktopAudioChunks: [],
isDesktopCapturing: false,
desktopRecordingInterval: null,
// Browser tab caption stream
desktopWordBuffer: [],
desktopPendingWords: [],
desktopWordAnimationTimer: null,
// Auto-save recording state
sessionStartTime: null,
sessionTranscript: [],
desktopSessionTranscript: [],
// DOM elements
elements: {},
/**
* Initialize the application
*/
init() {
this.cacheElements();
this.bindEvents();
this.connectSocket();
// Initialize settings module
Settings.init();
},
/**
* Cache DOM element references
*/
cacheElements() {
this.elements = {
// Mic controls
btnStart: document.getElementById('btn-start'),
btnStop: document.getElementById('btn-stop'),
btnClear: document.getElementById('btn-clear'),
autoSaveToggle: document.getElementById('auto-save-toggle'),
captions: document.getElementById('captions'),
micStatusDot: document.getElementById('mic-status-dot'),
micStatusText: document.getElementById('mic-status-text'),
// Desktop controls
btnDesktopToggle: document.getElementById('btn-desktop-toggle'),
desktopCaptions: document.getElementById('desktop-captions'),
desktopCaptionContainer: document.getElementById('desktop-caption-container'),
desktopStatusDot: document.getElementById('desktop-status-dot'),
desktopStatusText: document.getElementById('desktop-status-text'),
};
},
/**
* Bind event listeners
*/
bindEvents() {
// Mic controls
this.elements.btnStart.addEventListener('click', () => this.startRecording());
this.elements.btnStop.addEventListener('click', () => this.stopRecording());
this.elements.btnClear.addEventListener('click', () => this.clearCaptions());
// Desktop toggle
this.elements.btnDesktopToggle.addEventListener('click', () => this.toggleDesktopCapture());
// Load auto-save preference from localStorage
const savedPref = localStorage.getItem('autoSaveEnabled');
if (savedPref === 'true') {
this.elements.autoSaveToggle.checked = true;
}
// Save preference when toggled
this.elements.autoSaveToggle.addEventListener('change', (e) => {
localStorage.setItem('autoSaveEnabled', e.target.checked);
});
},
/**
* Connect to WebSocket server
*/
connectSocket() {
this.socket = io();
this.socket.on('connect', () => {
console.log('Connected to server');
this.setMicStatus('connected', 'Ready');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.setMicStatus('disconnected', 'Disconnected');
});
this.socket.on('transcription', (data) => {
this.addWords(data.text);
});
this.socket.on('desktop_transcription', (data) => {
this.addDesktopWords(data.text);
});
this.socket.on('settings_updated', (settings) => {
Settings.applySettings(settings);
});
this.socket.on('error', (data) => {
console.error('Server error:', data.message);
});
this.socket.on('recording_saved', (data) => {
console.log('Recording saved:', data.filename);
});
this.socket.on('recording_error', (data) => {
console.error('Recording error:', data.message);
});
},
/**
* Update mic status indicator
*/
setMicStatus(state, text) {
this.elements.micStatusDot.className = `dot ${state}`;
this.elements.micStatusText.textContent = text;
},
/**
* Update desktop status indicator
*/
setDesktopStatus(state, text) {
this.elements.desktopStatusDot.className = `dot ${state}`;
this.elements.desktopStatusText.textContent = text;
},
/**
* Start mic audio recording
*/
async startRecording() {
try {
this.audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
}
});
this.isRecording = true;
this.elements.btnStart.disabled = true;
this.elements.btnStop.disabled = false;
this.setMicStatus('recording', 'Recording...');
// Reset session transcript for auto-save
this.sessionStartTime = new Date();
this.sessionTranscript = [];
// Start the recording cycle
this.startRecordingCycle();
} catch (error) {
console.error('Error starting recording:', error);
this.setMicStatus('error', 'Microphone access denied');
}
},
/**
* Start a recording cycle - record for a duration, then send and restart
*/
startRecordingCycle() {
if (!this.isRecording || !this.audioStream) return;
// Determine best supported MIME type
let mimeType = 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
mimeType = 'audio/webm;codecs=opus';
}
this.audioChunks = [];
this.mediaRecorder = new MediaRecorder(this.audioStream, { mimeType });
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = () => {
// Create a complete blob from all chunks
if (this.audioChunks.length > 0) {
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
this.sendAudioBlob(blob);
}
// Start next cycle if still recording
if (this.isRecording) {
this.startRecordingCycle();
}
};
// Start recording
this.mediaRecorder.start();
// Stop after the configured duration to get a complete blob
// Using 1.5 seconds for more responsive streaming
const chunkDuration = 1500;
this.recordingInterval = setTimeout(() => {
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
}
}, chunkDuration);
},
/**
* Stop mic audio recording
*/
stopRecording() {
this.isRecording = false;
// Clear the recording interval
if (this.recordingInterval) {
clearTimeout(this.recordingInterval);
this.recordingInterval = null;
}
// Stop the media recorder
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
}
// Stop all tracks
if (this.audioStream) {
this.audioStream.getTracks().forEach(track => track.stop());
this.audioStream = null;
}
this.elements.btnStart.disabled = false;
this.elements.btnStop.disabled = true;
this.setMicStatus('connected', 'Ready');
// Auto-save if enabled and we have content
if (this.elements.autoSaveToggle.checked && this.sessionTranscript.length > 0) {
this.saveRecording();
}
},
/**
* Send complete audio blob to server
*/
sendAudioBlob(blob) {
const reader = new FileReader();
reader.onloadend = () => {
// Get base64 data without the data URL prefix
const base64 = reader.result.split(',')[1];
this.socket.emit('audio_data', {
audio: base64,
format: 'webm'
});
};
reader.readAsDataURL(blob);
},
/**
* Add words to the continuous caption stream
*/
addWords(text) {
if (!text.trim()) return;
// Split incoming text into words
const newWords = text.trim().split(/\s+/);
// Add to pending queue for animated display
this.pendingWords.push(...newWords);
// Accumulate to session transcript for auto-save
if (this.isRecording) {
this.sessionTranscript.push(...newWords);
}
// Start animation if not already running
if (!this.wordAnimationTimer) {
this.animateNextWord();
}
},
/**
* Animate words appearing one by one
*/
animateNextWord() {
if (this.pendingWords.length === 0) {
this.wordAnimationTimer = null;
return;
}
// Get next word from queue
const word = this.pendingWords.shift();
this.wordBuffer.push(word);
// Get max words from settings
const maxWords = Settings.current.max_words || 30;
// Trim buffer to max words
while (this.wordBuffer.length > maxWords) {
this.wordBuffer.shift();
}
// Update display
this.updateCaptionDisplay();
// Calculate delay based on pending words
// Faster if more words pending, slower if caught up
const baseDelay = 80; // ms per word
const minDelay = 30;
const delay = this.pendingWords.length > 10 ? minDelay : baseDelay;
// Schedule next word
this.wordAnimationTimer = setTimeout(() => {
this.animateNextWord();
}, delay);
},
/**
* Update the caption display with current word buffer
*/
updateCaptionDisplay() {
const text = this.wordBuffer.join(' ');
this.elements.captions.textContent = text;
},
/**
* Clear all captions (both mic and desktop)
*/
clearCaptions() {
// Clear mic animation timer
if (this.wordAnimationTimer) {
clearTimeout(this.wordAnimationTimer);
this.wordAnimationTimer = null;
}
this.wordBuffer = [];
this.pendingWords = [];
this.elements.captions.textContent = '';
// Clear browser tab animation timer
if (this.desktopWordAnimationTimer) {
clearTimeout(this.desktopWordAnimationTimer);
this.desktopWordAnimationTimer = null;
}
this.desktopWordBuffer = [];
this.desktopPendingWords = [];
this.elements.desktopCaptions.textContent = '';
},
// =========================================================================
// Browser Tab Audio Capture
// =========================================================================
/**
* Toggle browser tab audio capture on/off
*/
async toggleDesktopCapture() {
if (this.isDesktopCapturing) {
this.stopDesktopCapture();
} else {
await this.startDesktopCapture();
}
},
/**
* Start browser tab audio capture via getDisplayMedia
*/
async startDesktopCapture() {
// Check browser support
if (!navigator.mediaDevices.getDisplayMedia) {
alert('Browser tab audio capture is not supported in this browser.');
return;
}
try {
// Request browser tab capture with audio (preferCurrentTab helps default to tabs)
this.desktopStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser', // Prefer browser tabs
},
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
preferCurrentTab: false, // Don't default to current tab (we want to pick)
selfBrowserSurface: 'exclude', // Exclude this tab from picker
});
// Check if audio track was granted
const audioTracks = this.desktopStream.getAudioTracks();
if (audioTracks.length === 0) {
alert('No audio track available. Please select a browser tab that has audio playing.');
this.desktopStream.getTracks().forEach(track => track.stop());
this.desktopStream = null;
return;
}
// Stop video track immediately (we only need audio)
this.desktopStream.getVideoTracks().forEach(track => track.stop());
// Handle user stopping share via browser UI
audioTracks[0].addEventListener('ended', () => {
this.handleDesktopShareEnded();
});
this.isDesktopCapturing = true;
this.elements.btnDesktopToggle.classList.add('active');
this.elements.btnDesktopToggle.innerHTML = '<span class="icon">&#127911;</span> Stop Tab';
this.setDesktopStatus('recording', 'Capturing...');
// Show desktop caption container
this.elements.desktopCaptionContainer.classList.remove('hidden');
// Reset desktop session transcript
this.desktopSessionTranscript = [];
// Start recording cycle
this.startDesktopRecordingCycle();
} catch (error) {
console.error('Error starting desktop capture:', error);
if (error.name === 'NotAllowedError') {
// User cancelled the picker
this.setDesktopStatus('', 'Cancelled');
} else {
this.setDesktopStatus('error', 'Failed to start');
}
}
},
/**
* Stop desktop audio capture
*/
stopDesktopCapture() {
this.isDesktopCapturing = false;
// Clear recording interval
if (this.desktopRecordingInterval) {
clearTimeout(this.desktopRecordingInterval);
this.desktopRecordingInterval = null;
}
// Stop media recorder
if (this.desktopMediaRecorder && this.desktopMediaRecorder.state === 'recording') {
this.desktopMediaRecorder.stop();
}
// Stop all tracks
if (this.desktopStream) {
this.desktopStream.getTracks().forEach(track => track.stop());
this.desktopStream = null;
}
this.elements.btnDesktopToggle.classList.remove('active');
this.elements.btnDesktopToggle.innerHTML = '<span class="icon">&#127911;</span> Browser Tab';
this.setDesktopStatus('', 'Stopped');
},
/**
* Handle user clicking "Stop sharing" in browser UI
*/
handleDesktopShareEnded() {
console.log('Browser tab share ended by user');
this.stopDesktopCapture();
},
/**
* Start browser tab recording cycle - record for a duration, then send and restart
*/
startDesktopRecordingCycle() {
if (!this.isDesktopCapturing || !this.desktopStream) return;
const audioTracks = this.desktopStream.getAudioTracks();
if (audioTracks.length === 0) {
this.stopDesktopCapture();
return;
}
// Create audio-only stream from the desktop stream's audio track
const audioOnlyStream = new MediaStream([audioTracks[0]]);
let mimeType = 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
mimeType = 'audio/webm;codecs=opus';
}
this.desktopAudioChunks = [];
this.desktopMediaRecorder = new MediaRecorder(audioOnlyStream, { mimeType });
this.desktopMediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.desktopAudioChunks.push(event.data);
}
};
this.desktopMediaRecorder.onstop = () => {
if (this.desktopAudioChunks.length > 0) {
const blob = new Blob(this.desktopAudioChunks, { type: 'audio/webm' });
this.sendDesktopAudioBlob(blob);
}
// Continue cycle if still capturing
if (this.isDesktopCapturing) {
this.startDesktopRecordingCycle();
}
};
this.desktopMediaRecorder.start();
// Same 1.5s chunk duration as mic
const chunkDuration = 1500;
this.desktopRecordingInterval = setTimeout(() => {
if (this.desktopMediaRecorder && this.desktopMediaRecorder.state === 'recording') {
this.desktopMediaRecorder.stop();
}
}, chunkDuration);
},
/**
* Send browser tab audio blob to server
*/
sendDesktopAudioBlob(blob) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result.split(',')[1];
this.socket.emit('desktop_audio_data', {
audio: base64,
format: 'webm'
});
};
reader.readAsDataURL(blob);
},
/**
* Add words to the browser tab caption stream
*/
addDesktopWords(text) {
if (!text.trim()) return;
const newWords = text.trim().split(/\s+/);
this.desktopPendingWords.push(...newWords);
// Track for auto-save if needed
if (this.isDesktopCapturing) {
this.desktopSessionTranscript.push(...newWords);
}
if (!this.desktopWordAnimationTimer) {
this.animateNextDesktopWord();
}
},
/**
* Animate browser tab words appearing one by one
*/
animateNextDesktopWord() {
if (this.desktopPendingWords.length === 0) {
this.desktopWordAnimationTimer = null;
return;
}
const word = this.desktopPendingWords.shift();
this.desktopWordBuffer.push(word);
// Get max words from desktop settings
const maxWords = Settings.current.desktop_max_words || 30;
while (this.desktopWordBuffer.length > maxWords) {
this.desktopWordBuffer.shift();
}
this.updateDesktopCaptionDisplay();
const baseDelay = 80;
const minDelay = 30;
const delay = this.desktopPendingWords.length > 10 ? minDelay : baseDelay;
this.desktopWordAnimationTimer = setTimeout(() => {
this.animateNextDesktopWord();
}, delay);
},
/**
* Update the browser tab caption display
*/
updateDesktopCaptionDisplay() {
const text = this.desktopWordBuffer.join(' ');
this.elements.desktopCaptions.textContent = text;
},
/**
* Save the current recording session
*/
saveRecording() {
if (!this.sessionStartTime) return;
const endTime = new Date();
const transcript = this.sessionTranscript.join(' ');
this.socket.emit('save_recording', {
startTime: this.sessionStartTime.toISOString(),
endTime: endTime.toISOString(),
transcript: transcript,
wordCount: this.sessionTranscript.length
});
// Reset session state
this.sessionStartTime = null;
this.sessionTranscript = [];
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
App.init();
});