/** * 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 = '🎧 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 = '🎧 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(); });