643 lines
20 KiB
JavaScript
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">🎧</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">🎧</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();
|
|
});
|