Initial commit: Live Captions web application

Real-time speech-to-text using OpenAI Whisper (faster-whisper).
Features browser audio capture, WebSocket streaming, and customizable display settings.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 08:53:40 -07:00
commit c7becf330c
18 changed files with 2633 additions and 0 deletions

355
static/js/app.js Normal file
View File

@@ -0,0 +1,355 @@
/**
* Live Captions - Main Application
* Handles audio capture and WebSocket communication
*/
const App = {
// WebSocket connection
socket: null,
// Audio recording
mediaRecorder: null,
audioStream: null,
audioChunks: [],
isRecording: false,
recordingInterval: null,
// Continuous caption stream
wordBuffer: [],
pendingWords: [],
wordAnimationTimer: null,
// Auto-save recording state
sessionStartTime: null,
sessionTranscript: [],
// 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 = {
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'),
statusDot: document.getElementById('status-dot'),
statusText: document.getElementById('status-text'),
};
},
/**
* Bind event listeners
*/
bindEvents() {
this.elements.btnStart.addEventListener('click', () => this.startRecording());
this.elements.btnStop.addEventListener('click', () => this.stopRecording());
this.elements.btnClear.addEventListener('click', () => this.clearCaptions());
// 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.setStatus('connected', 'Connected');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.setStatus('disconnected', 'Disconnected');
});
this.socket.on('transcription', (data) => {
this.addWords(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 status indicator
*/
setStatus(state, text) {
this.elements.statusDot.className = `dot ${state}`;
this.elements.statusText.textContent = text;
},
/**
* Start 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.setStatus('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.setStatus('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 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.setStatus('connected', 'Connected');
// 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
*/
clearCaptions() {
// Clear animation timer
if (this.wordAnimationTimer) {
clearTimeout(this.wordAnimationTimer);
this.wordAnimationTimer = null;
}
this.wordBuffer = [];
this.pendingWords = [];
this.elements.captions.textContent = '';
},
/**
* 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();
});

204
static/js/recordings.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Live Captions - Recordings Panel
* Handles viewing and managing saved recordings
*/
const Recordings = {
// Current state
recordings: [],
currentRecording: null,
// DOM elements
elements: {},
/**
* Initialize the recordings panel
*/
init() {
this.cacheElements();
this.bindEvents();
},
/**
* Cache DOM element references
*/
cacheElements() {
this.elements = {
btnRecordings: document.getElementById('btn-recordings'),
btnClose: document.getElementById('btn-close-recordings'),
btnBackToList: document.getElementById('btn-back-to-list'),
btnDelete: document.getElementById('btn-delete-recording'),
panel: document.getElementById('recordings-panel'),
overlay: document.getElementById('overlay'),
recordingsList: document.getElementById('recordings-list'),
recordingViewer: document.getElementById('recording-viewer'),
viewerFilename: document.getElementById('viewer-filename'),
viewerContent: document.getElementById('viewer-content'),
};
},
/**
* Bind event listeners
*/
bindEvents() {
this.elements.btnRecordings.addEventListener('click', () => this.openPanel());
this.elements.btnClose.addEventListener('click', () => this.closePanel());
this.elements.btnBackToList.addEventListener('click', () => this.showList());
this.elements.btnDelete.addEventListener('click', () => this.deleteCurrentRecording());
// Close on overlay click (but check if it's not settings panel)
this.elements.overlay.addEventListener('click', () => {
if (!this.elements.panel.classList.contains('hidden')) {
this.closePanel();
}
});
},
/**
* Open the recordings panel
*/
openPanel() {
this.elements.panel.classList.remove('hidden');
this.elements.overlay.classList.remove('hidden');
this.showList();
this.loadRecordings();
},
/**
* Close the recordings panel
*/
closePanel() {
this.elements.panel.classList.add('hidden');
this.elements.overlay.classList.add('hidden');
this.currentRecording = null;
},
/**
* Show the recordings list view
*/
showList() {
this.elements.recordingsList.classList.remove('hidden');
this.elements.recordingViewer.classList.add('hidden');
},
/**
* Show the recording viewer
*/
showViewer() {
this.elements.recordingsList.classList.add('hidden');
this.elements.recordingViewer.classList.remove('hidden');
},
/**
* Load recordings from the API
*/
async loadRecordings() {
this.elements.recordingsList.innerHTML = '<p class="recordings-empty">Loading recordings...</p>';
try {
const response = await fetch('/api/recordings');
if (!response.ok) throw new Error('Failed to load recordings');
this.recordings = await response.json();
this.renderRecordingsList();
} catch (error) {
console.error('Error loading recordings:', error);
this.elements.recordingsList.innerHTML =
'<p class="recordings-empty">Failed to load recordings</p>';
}
},
/**
* Render the recordings list
*/
renderRecordingsList() {
if (this.recordings.length === 0) {
this.elements.recordingsList.innerHTML =
'<p class="recordings-empty">No recordings yet.<br>Enable auto-save and record some captions!</p>';
return;
}
const html = this.recordings.map(recording => `
<div class="recording-item" data-filename="${recording.filename}">
<div class="recording-info">
<span class="recording-date">${recording.date}</span>
<span class="recording-meta">${this.formatFileSize(recording.size)}</span>
</div>
<span class="recording-arrow">&rsaquo;</span>
</div>
`).join('');
this.elements.recordingsList.innerHTML = html;
// Bind click events to items
this.elements.recordingsList.querySelectorAll('.recording-item').forEach(item => {
item.addEventListener('click', () => {
const filename = item.dataset.filename;
this.viewRecording(filename);
});
});
},
/**
* Format file size in human-readable format
*/
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
/**
* View a specific recording
*/
async viewRecording(filename) {
try {
const response = await fetch(`/api/recordings/${encodeURIComponent(filename)}`);
if (!response.ok) throw new Error('Failed to load recording');
const data = await response.json();
this.currentRecording = filename;
this.elements.viewerFilename.textContent = filename;
this.elements.viewerContent.textContent = data.content;
this.showViewer();
} catch (error) {
console.error('Error loading recording:', error);
alert('Failed to load recording');
}
},
/**
* Delete the currently viewed recording
*/
async deleteCurrentRecording() {
if (!this.currentRecording) return;
if (!confirm('Are you sure you want to delete this recording?')) {
return;
}
try {
const response = await fetch(`/api/recordings/${encodeURIComponent(this.currentRecording)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete recording');
// Remove from local list
this.recordings = this.recordings.filter(r => r.filename !== this.currentRecording);
this.currentRecording = null;
// Go back to list
this.showList();
this.renderRecordingsList();
} catch (error) {
console.error('Error deleting recording:', error);
alert('Failed to delete recording');
}
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
Recordings.init();
});

259
static/js/settings.js Normal file
View File

@@ -0,0 +1,259 @@
/**
* Settings Panel Module
* Handles user settings UI and persistence
*/
const Settings = {
// Current settings state
current: {},
// DOM elements
elements: {},
/**
* Initialize the settings module
*/
init() {
this.cacheElements();
this.bindEvents();
},
/**
* Cache DOM element references
*/
cacheElements() {
this.elements = {
panel: document.getElementById('settings-panel'),
overlay: document.getElementById('overlay'),
btnSettings: document.getElementById('btn-settings'),
btnClose: document.getElementById('btn-close-settings'),
btnSave: document.getElementById('btn-save-settings'),
btnReset: document.getElementById('btn-reset-settings'),
// Text settings
fontFamily: document.getElementById('font-family'),
fontSize: document.getElementById('font-size'),
fontSizeValue: document.getElementById('font-size-value'),
fontWeight: document.getElementById('font-weight'),
textColor: document.getElementById('text-color'),
textAlign: document.getElementById('text-align'),
// Background settings
backgroundColor: document.getElementById('background-color'),
backgroundOpacity: document.getElementById('background-opacity'),
opacityValue: document.getElementById('opacity-value'),
borderRadius: document.getElementById('border-radius'),
radiusValue: document.getElementById('radius-value'),
padding: document.getElementById('padding'),
paddingValue: document.getElementById('padding-value'),
// Behavior settings
maxWords: document.getElementById('max-words'),
maxWordsValue: document.getElementById('max-words-value'),
// Caption display
captionContainer: document.getElementById('caption-container'),
};
},
/**
* Bind event listeners
*/
bindEvents() {
// Panel open/close
this.elements.btnSettings.addEventListener('click', () => this.openPanel());
this.elements.btnClose.addEventListener('click', () => this.closePanel());
this.elements.overlay.addEventListener('click', () => this.closePanel());
// Save/Reset
this.elements.btnSave.addEventListener('click', () => this.saveSettings());
this.elements.btnReset.addEventListener('click', () => this.resetSettings());
// Live preview on input change
const inputs = [
'fontFamily', 'fontSize', 'fontWeight', 'textColor', 'textAlign',
'backgroundColor', 'backgroundOpacity', 'borderRadius', 'padding',
'maxWords'
];
inputs.forEach(name => {
const element = this.elements[name];
if (element) {
element.addEventListener('input', () => this.updatePreview());
}
});
// Update value displays for range inputs
this.elements.fontSize.addEventListener('input', (e) => {
this.elements.fontSizeValue.textContent = e.target.value;
});
this.elements.backgroundOpacity.addEventListener('input', (e) => {
this.elements.opacityValue.textContent = e.target.value;
});
this.elements.borderRadius.addEventListener('input', (e) => {
this.elements.radiusValue.textContent = e.target.value;
});
this.elements.padding.addEventListener('input', (e) => {
this.elements.paddingValue.textContent = e.target.value;
});
this.elements.maxWords.addEventListener('input', (e) => {
this.elements.maxWordsValue.textContent = e.target.value;
});
},
/**
* Open settings panel
*/
openPanel() {
this.elements.panel.classList.remove('hidden');
this.elements.overlay.classList.remove('hidden');
},
/**
* Close settings panel
*/
closePanel() {
this.elements.panel.classList.add('hidden');
this.elements.overlay.classList.add('hidden');
},
/**
* Apply settings to the UI
*/
applySettings(settings) {
this.current = settings;
// Update form values
this.elements.fontFamily.value = settings.font_family;
this.elements.fontSize.value = settings.font_size;
this.elements.fontSizeValue.textContent = settings.font_size;
this.elements.fontWeight.value = settings.font_weight;
this.elements.textColor.value = settings.text_color;
this.elements.textAlign.value = settings.text_align;
this.elements.backgroundColor.value = settings.background_color;
this.elements.backgroundOpacity.value = Math.round(settings.background_opacity * 100);
this.elements.opacityValue.textContent = Math.round(settings.background_opacity * 100);
this.elements.borderRadius.value = settings.border_radius;
this.elements.radiusValue.textContent = settings.border_radius;
this.elements.padding.value = settings.padding;
this.elements.paddingValue.textContent = settings.padding;
this.elements.maxWords.value = settings.max_words || 30;
this.elements.maxWordsValue.textContent = settings.max_words || 30;
// Apply to caption container
this.updatePreview();
},
/**
* Update live preview of caption styling
*/
updatePreview() {
const container = this.elements.captionContainer;
const opacity = this.elements.backgroundOpacity.value / 100;
// Parse background color and apply opacity
const bgColor = this.elements.backgroundColor.value;
const r = parseInt(bgColor.slice(1, 3), 16);
const g = parseInt(bgColor.slice(3, 5), 16);
const b = parseInt(bgColor.slice(5, 7), 16);
container.style.fontFamily = this.elements.fontFamily.value;
container.style.fontSize = `${this.elements.fontSize.value}px`;
container.style.fontWeight = this.elements.fontWeight.value;
container.style.color = this.elements.textColor.value;
container.style.textAlign = this.elements.textAlign.value;
container.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${opacity})`;
container.style.borderRadius = `${this.elements.borderRadius.value}px`;
container.style.padding = `${this.elements.padding.value}px`;
// Store max words for caption management
this.current.max_words = parseInt(this.elements.maxWords.value);
},
/**
* Get current form values as settings object
*/
getFormValues() {
return {
font_family: this.elements.fontFamily.value,
font_size: parseInt(this.elements.fontSize.value),
font_weight: this.elements.fontWeight.value,
text_color: this.elements.textColor.value,
text_align: this.elements.textAlign.value,
background_color: this.elements.backgroundColor.value,
background_opacity: this.elements.backgroundOpacity.value / 100,
border_radius: parseInt(this.elements.borderRadius.value),
padding: parseInt(this.elements.padding.value),
max_words: parseInt(this.elements.maxWords.value),
};
},
/**
* Save settings to server
*/
async saveSettings() {
const settings = this.getFormValues();
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (response.ok) {
this.current = await response.json();
this.closePanel();
console.log('Settings saved');
} else {
console.error('Failed to save settings');
}
} catch (error) {
console.error('Error saving settings:', error);
}
},
/**
* Reset settings to defaults
*/
async resetSettings() {
if (!confirm('Reset all settings to defaults?')) {
return;
}
try {
const response = await fetch('/api/settings/reset', {
method: 'POST',
});
if (response.ok) {
const settings = await response.json();
this.applySettings(settings);
console.log('Settings reset to defaults');
} else {
console.error('Failed to reset settings');
}
} catch (error) {
console.error('Error resetting settings:', error);
}
},
/**
* Fetch settings from server
*/
async fetchSettings() {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const settings = await response.json();
this.applySettings(settings);
}
} catch (error) {
console.error('Error fetching settings:', error);
}
}
};