First commit of local Closed Caption App

This commit is contained in:
2025-08-05 15:17:40 -07:00
commit f4c3a024bd
21 changed files with 3023 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #000;
color: #fff;
overflow: hidden;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
.controls {
background: #1a1a1a;
padding: 20px;
border-bottom: 1px solid #333;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h3 {
color: #fff;
font-size: 18px;
margin: 0;
}
.header a {
text-decoration: none;
}
.header .btn {
margin: 0;
}
.control-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
-webkit-app-region: no-drag;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 14px;
color: #ccc;
}
.control-group input,
.control-group select {
padding: 8px;
border: 1px solid #333;
background: #2a2a2a;
color: #fff;
border-radius: 4px;
}
.control-group input[type="color"] {
height: 40px;
cursor: pointer;
}
.control-group input[type="range"] {
width: 100%;
}
#font-size-display {
color: #ccc;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #4CAF50;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #45a049;
}
.btn-secondary {
background: #f44336;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #da190b;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.transcription-display {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #000;
position: relative;
}
#transcription-text {
text-align: center;
font-size: 32px;
color: #fff;
line-height: 1.5;
max-width: 90%;
word-wrap: break-word;
transition: opacity 0.3s ease;
}
.fade-out {
opacity: 0 !important;
}
.audio-level-container {
display: flex;
align-items: center;
gap: 10px;
}
.audio-level-bar {
flex: 1;
height: 20px;
background: #333;
border-radius: 10px;
overflow: hidden;
position: relative;
}
.audio-level-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50 0%, #FFC107 50%, #f44336 100%);
width: 0%;
transition: width 0.1s ease;
}
#audio-level-text {
color: #ccc;
font-size: 12px;
min-width: 30px;
}
#queue-status {
color: #ccc;
font-size: 12px;
font-family: monospace;
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Transcription Display</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="google-font-link" rel="stylesheet" href="">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: drag;
user-select: none;
}
#display-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #000;
}
#transcription-text {
text-align: center;
font-size: 32px;
color: #fff;
line-height: 1.5;
word-wrap: break-word;
transition: opacity 0.3s ease;
font-family: Arial, sans-serif;
}
.fade-out {
opacity: 0 !important;
}
</style>
</head>
<body>
<div id="display-container">
<div id="transcription-text"></div>
</div>
<script src="js/display.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' http://localhost:3000 https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self' 'unsafe-inline' http://localhost:3000; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 ws://localhost:3000">
<title>Live Transcription</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="google-font-link" rel="stylesheet" href="">
</head>
<body>
<div id="app">
<div id="controls" class="controls">
<div class="header">
<h3>Live Transcription</h3>
<button id="settings-btn" class="btn btn-small">⚙️ Settings</button>
</div>
<div class="control-panel">
<div class="control-group">
<label>Backend:</label>
<select id="backend-select">
<option value="whisper-local">Whisper (Local)</option>
<option value="whisper-remote">Whisper (Remote)</option>
<option value="elevenlabs">ElevenLabs (Scribe)</option>
</select>
</div>
<div class="control-group">
<label>Text Duration (seconds):</label>
<input type="number" id="duration-input" min="1" max="60" value="5">
</div>
<div class="control-group">
<label>Transcription Speed:</label>
<select id="chunk-size-select">
<option value="1000">Very Fast (1s chunks)</option>
<option value="2000" selected>Fast (2s chunks)</option>
<option value="3000">Balanced (3s chunks)</option>
<option value="5000">Quality (5s chunks)</option>
</select>
</div>
<div class="control-group">
<label>Text Color:</label>
<input type="color" id="color-input" value="#ffffff">
</div>
<div class="control-group">
<label>Background Color:</label>
<input type="color" id="bg-color-input" value="#000000">
</div>
<div class="control-group">
<label>Font Family:</label>
<select id="font-select">
<optgroup label="System Fonts">
<option value="Arial, sans-serif">Arial</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="Georgia, serif">Georgia</option>
<option value="Verdana, sans-serif">Verdana</option>
<option value="'Trebuchet MS', sans-serif">Trebuchet MS</option>
</optgroup>
<optgroup label="Google Fonts">
<option value="'Roboto', sans-serif" data-google="Roboto">Roboto</option>
<option value="'Open Sans', sans-serif" data-google="Open+Sans">Open Sans</option>
<option value="'Lato', sans-serif" data-google="Lato">Lato</option>
<option value="'Montserrat', sans-serif" data-google="Montserrat">Montserrat</option>
<option value="'Poppins', sans-serif" data-google="Poppins">Poppins</option>
<option value="'Raleway', sans-serif" data-google="Raleway">Raleway</option>
</optgroup>
</select>
</div>
<div class="control-group">
<label>Font Size:</label>
<input type="range" id="font-size-input" min="16" max="72" value="32">
<span id="font-size-display">32px</span>
</div>
<div class="control-group">
<button id="start-btn" class="btn btn-primary">Start Recording</button>
<button id="stop-btn" class="btn btn-secondary" disabled>Stop Recording</button>
</div>
<div class="control-group">
<label>Audio Level:</label>
<div class="audio-level-container">
<div id="audio-level-bar" class="audio-level-bar">
<div id="audio-level-fill" class="audio-level-fill"></div>
</div>
<span id="audio-level-text">0%</span>
</div>
</div>
<div class="control-group">
<label>Queue Status:</label>
<div id="queue-status">Queue: 0, Running: 0</div>
</div>
<div class="control-group">
<button id="toggle-display-btn" class="btn">Open Display Window</button>
</div>
</div>
</div>
<div id="transcription-display" class="transcription-display">
<div id="transcription-text"></div>
</div>
</div>
<script src="http://localhost:3000/socket.io/socket.io.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+444
View File
@@ -0,0 +1,444 @@
let socket;
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let displayWindowOpen = false;
let currentTimeout;
// Store current settings
let displaySettings = {
textColor: '#ffffff',
bgColor: '#000000',
fontFamily: 'Arial, sans-serif',
fontSize: '32px',
duration: 5,
googleFont: null
};
// Wait for DOM to be fully loaded
document.addEventListener('DOMContentLoaded', () => {
// Initialize socket connection
try {
socket = io('http://localhost:3000');
socket.on('connect', () => {
console.log('Connected to server');
});
socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
});
} catch (error) {
console.error('Failed to initialize socket:', error);
}
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
const backendSelect = document.getElementById('backend-select');
const durationInput = document.getElementById('duration-input');
const colorInput = document.getElementById('color-input');
const bgColorInput = document.getElementById('bg-color-input');
const fontSelect = document.getElementById('font-select');
const fontSizeInput = document.getElementById('font-size-input');
const fontSizeDisplay = document.getElementById('font-size-display');
const transcriptionText = document.getElementById('transcription-text');
const transcriptionDisplay = document.getElementById('transcription-display');
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
const googleFontLink = document.getElementById('google-font-link');
const audioLevelFill = document.getElementById('audio-level-fill');
const audioLevelText = document.getElementById('audio-level-text');
const queueStatus = document.getElementById('queue-status');
const chunkSizeSelect = document.getElementById('chunk-size-select');
const settingsBtn = document.getElementById('settings-btn');
// Load available backends
async function loadBackends() {
try {
const response = await fetch('http://localhost:3000/api/backends');
const data = await response.json();
backendSelect.innerHTML = '';
data.backends.forEach(backend => {
const option = document.createElement('option');
option.value = backend.id;
option.textContent = backend.name + (backend.available ? '' : ' (Unavailable)');
option.disabled = !backend.available;
backendSelect.appendChild(option);
});
backendSelect.value = data.current;
} catch (error) {
console.error('Failed to load backends:', error);
}
}
// Initialize
loadBackends();
loadAppSettings();
// Update queue status periodically
async function updateQueueStatus() {
try {
const response = await fetch('http://localhost:3000/api/queue-status');
const status = await response.json();
queueStatus.textContent = `Queue: ${status.queueLength}, Running: ${status.runningJobs}`;
} catch (error) {
// Silently fail - not critical
}
}
setInterval(updateQueueStatus, 2000); // Update every 2 seconds
// Save app setting
async function saveAppSetting(key, value) {
try {
await fetch('http://localhost:3000/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value })
});
} catch (error) {
console.warn('Could not save app setting:', key, error);
}
}
// Load app settings
async function loadAppSettings() {
try {
const response = await fetch('http://localhost:3000/api/settings');
const settings = await response.json();
// Apply settings to UI elements
if (settings.selectedBackend) {
backendSelect.value = settings.selectedBackend;
}
if (settings.textDuration) {
durationInput.value = settings.textDuration;
}
if (settings.textColor) {
colorInput.value = settings.textColor;
}
if (settings.backgroundColor) {
bgColorInput.value = settings.backgroundColor;
}
if (settings.fontFamily) {
fontSelect.value = settings.fontFamily;
}
if (settings.fontSize) {
fontSizeInput.value = settings.fontSize;
fontSizeDisplay.textContent = settings.fontSize + 'px';
}
if (settings.chunkSize) {
chunkSizeSelect.value = settings.chunkSize;
}
// Update display settings
updateDisplaySettings();
console.log('App settings loaded');
} catch (error) {
console.warn('Could not load app settings, using defaults:', error);
}
}
// Update display settings and notify display window
function updateDisplaySettings() {
displaySettings = {
textColor: colorInput.value,
bgColor: bgColorInput.value,
fontFamily: fontSelect.value,
fontSize: fontSizeInput.value + 'px',
duration: parseInt(durationInput.value),
googleFont: fontSelect.options[fontSelect.selectedIndex].getAttribute('data-google')
};
// Apply to local display
transcriptionText.style.color = displaySettings.textColor;
transcriptionText.style.fontSize = displaySettings.fontSize;
transcriptionText.style.fontFamily = displaySettings.fontFamily;
transcriptionDisplay.style.backgroundColor = displaySettings.bgColor;
// Update Google Font link
if (displaySettings.googleFont) {
googleFontLink.href = `https://fonts.googleapis.com/css2?family=${displaySettings.googleFont}&display=swap`;
}
// Send to display window
if (window.electronAPI) {
window.electronAPI.updateDisplaySettings(displaySettings);
}
}
// Font selection handling with auto-save
fontSelect.addEventListener('change', () => {
updateDisplaySettings();
saveAppSetting('fontFamily', fontSelect.value);
const selectedOption = fontSelect.options[fontSelect.selectedIndex];
const googleFont = selectedOption.getAttribute('data-google');
saveAppSetting('googleFont', googleFont);
});
colorInput.addEventListener('change', () => {
updateDisplaySettings();
saveAppSetting('textColor', colorInput.value);
});
bgColorInput.addEventListener('change', () => {
updateDisplaySettings();
saveAppSetting('backgroundColor', bgColorInput.value);
});
durationInput.addEventListener('change', () => {
updateDisplaySettings();
saveAppSetting('textDuration', parseInt(durationInput.value));
});
fontSizeInput.addEventListener('input', (e) => {
fontSizeDisplay.textContent = e.target.value + 'px';
updateDisplaySettings();
saveAppSetting('fontSize', parseInt(e.target.value));
});
chunkSizeSelect.addEventListener('change', () => {
saveAppSetting('chunkSize', parseInt(chunkSizeSelect.value));
});
// Backend selection
backendSelect.addEventListener('change', async (e) => {
try {
await fetch('http://localhost:3000/api/backend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backend: e.target.value })
});
// Save to settings
saveAppSetting('selectedBackend', e.target.value);
} catch (error) {
console.error('Failed to switch backend:', error);
}
});
// Display window toggle
toggleDisplayBtn.addEventListener('click', async () => {
if (window.electronAPI) {
displayWindowOpen = await window.electronAPI.toggleDisplayWindow();
toggleDisplayBtn.textContent = displayWindowOpen ? 'Close Display Window' : 'Open Display Window';
// Send current settings to the new window
if (displayWindowOpen) {
setTimeout(() => {
window.electronAPI.updateDisplaySettings(displaySettings);
}, 500);
}
}
});
// Listen for display window close event
if (window.electronAPI) {
window.electronAPI.onDisplayWindowClosed(() => {
displayWindowOpen = false;
toggleDisplayBtn.textContent = 'Open Display Window';
});
}
// Socket event listeners
if (socket) {
socket.on('transcription', (data) => {
displayTranscription(data.text);
// Also send to display window via IPC
if (window.electronAPI && displayWindowOpen) {
window.electronAPI.sendTranscription(data);
}
});
}
function displayTranscription(text) {
transcriptionText.textContent = text;
transcriptionText.classList.remove('fade-out');
if (currentTimeout) {
clearTimeout(currentTimeout);
}
const duration = displaySettings.duration * 1000;
currentTimeout = setTimeout(() => {
transcriptionText.classList.add('fade-out');
setTimeout(() => {
transcriptionText.textContent = '';
}, 300);
}, duration);
}
// Audio level monitoring
let audioContext;
let analyser;
let microphone;
let dataArray;
let audioLevelInterval;
function startAudioLevelMonitoring(stream) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
microphone = audioContext.createMediaStreamSource(stream);
// Configure analyser for better responsiveness
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.3;
analyser.minDecibels = -90;
analyser.maxDecibels = -10;
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
microphone.connect(analyser);
function updateAudioLevel() {
if (!isRecording) return;
analyser.getByteTimeDomainData(dataArray);
// Calculate RMS of time domain data (more accurate for level detection)
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
const sample = (dataArray[i] - 128) / 128; // Convert to -1 to 1 range
sum += sample * sample;
}
const rms = Math.sqrt(sum / bufferLength);
// Apply some smoothing and scaling
const level = Math.min(rms * 10, 1); // Scale up and cap at 1
// Update UI
const percentage = Math.round(level * 100);
audioLevelFill.style.width = `${percentage}%`;
audioLevelText.textContent = `${percentage}%`;
// Store current level for threshold checking
window.currentAudioLevel = level;
}
// Update at 60fps for smooth animation
audioLevelInterval = setInterval(updateAudioLevel, 16);
}
// Audio recording
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Start audio level monitoring
startAudioLevelMonitoring(stream);
const options = { mimeType: 'audio/webm' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options.mimeType = 'audio/ogg';
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options.mimeType = '';
}
}
mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
await sendAudioForTranscription(audioBlob);
};
mediaRecorder.start();
isRecording = true;
// Auto-stop and restart recording based on selected chunk size
const chunkSize = parseInt(chunkSizeSelect.value);
const recordingInterval = setInterval(() => {
if (isRecording && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
setTimeout(() => {
if (isRecording) {
audioChunks = [];
mediaRecorder.start();
}
}, 100);
} else if (!isRecording) {
clearInterval(recordingInterval);
}
}, chunkSize);
startBtn.disabled = true;
stopBtn.disabled = false;
} catch (error) {
console.error('Error starting recording:', error);
alert('Failed to start recording. Please check microphone permissions.');
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
isRecording = false;
startBtn.disabled = false;
stopBtn.disabled = true;
// Stop audio level monitoring
if (audioLevelInterval) {
clearInterval(audioLevelInterval);
audioLevelInterval = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
// Reset audio level display
audioLevelFill.style.width = '0%';
audioLevelText.textContent = '0%';
}
async function sendAudioForTranscription(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'audio.webm');
try {
const response = await fetch('http://localhost:3000/api/transcribe', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
console.error('Transcription error:', error);
}
} catch (error) {
console.error('Failed to send audio:', error);
}
}
// Event listeners
startBtn.addEventListener('click', () => {
console.log('Start button clicked');
startRecording();
});
stopBtn.addEventListener('click', () => {
console.log('Stop button clicked');
stopRecording();
});
// Initial settings application
updateDisplaySettings();
// Settings button
settingsBtn.addEventListener('click', () => {
window.location.href = 'settings.html';
});
});
+56
View File
@@ -0,0 +1,56 @@
let currentTimeout;
let displaySettings = {
textColor: '#ffffff',
bgColor: '#000000',
fontFamily: 'Arial, sans-serif',
fontSize: '32px',
duration: 5
};
const transcriptionText = document.getElementById('transcription-text');
const displayContainer = document.getElementById('display-container');
const googleFontLink = document.getElementById('google-font-link');
// Apply settings
function applySettings(settings) {
displaySettings = { ...displaySettings, ...settings };
transcriptionText.style.color = displaySettings.textColor;
transcriptionText.style.fontSize = displaySettings.fontSize;
transcriptionText.style.fontFamily = displaySettings.fontFamily;
displayContainer.style.backgroundColor = displaySettings.bgColor;
// Handle Google Fonts
if (settings.googleFont) {
googleFontLink.href = `https://fonts.googleapis.com/css2?family=${settings.googleFont}&display=swap`;
}
}
// Display transcription
function displayTranscription(text) {
transcriptionText.textContent = text;
transcriptionText.classList.remove('fade-out');
if (currentTimeout) {
clearTimeout(currentTimeout);
}
const duration = displaySettings.duration * 1000;
currentTimeout = setTimeout(() => {
transcriptionText.classList.add('fade-out');
setTimeout(() => {
transcriptionText.textContent = '';
}, 300);
}, duration);
}
// Listen for IPC messages
if (window.electronAPI) {
window.electronAPI.onTranscription((data) => {
displayTranscription(data.text);
});
window.electronAPI.onUpdateSettings((settings) => {
applySettings(settings);
});
}
+353
View File
@@ -0,0 +1,353 @@
let isCalibrating = false;
let calibrationData = [];
let calibrationInterval;
let audioContext;
let analyser;
let microphone;
document.addEventListener('DOMContentLoaded', () => {
// Get all form elements
const elements = {
openaiKey: document.getElementById('openai-key'),
elevenlabsKey: document.getElementById('elevenlabs-key'),
whisperModel: document.getElementById('whisper-model'),
gpuAcceleration: document.getElementById('gpu-acceleration'),
audioThreshold: document.getElementById('audio-threshold'),
thresholdValue: document.getElementById('threshold-value'),
chunkSize: document.getElementById('chunk-size'),
defaultBackend: document.getElementById('default-backend'),
textDuration: document.getElementById('text-duration'),
textColor: document.getElementById('text-color'),
bgColor: document.getElementById('bg-color'),
fontFamily: document.getElementById('font-family'),
fontSize: document.getElementById('font-size'),
fontSizeValue: document.getElementById('font-size-value'),
serverPort: document.getElementById('server-port'),
// Calibration elements
startCalibration: document.getElementById('start-calibration'),
stopCalibration: document.getElementById('stop-calibration'),
calibrationProgress: document.getElementById('calibration-progress'),
ambientLevel: document.getElementById('ambient-level'),
calibrationStatus: document.getElementById('calibration-status'),
// Action buttons
saveSettings: document.getElementById('save-settings'),
resetSettings: document.getElementById('reset-settings'),
testConnection: document.getElementById('test-connection'),
detectGpu: document.getElementById('detect-gpu'),
statusMessage: document.getElementById('status-message')
};
// Update threshold value display
elements.audioThreshold.addEventListener('input', (e) => {
elements.thresholdValue.textContent = parseFloat(e.target.value).toFixed(3);
});
// Update font size value display
elements.fontSize.addEventListener('input', (e) => {
elements.fontSizeValue.textContent = e.target.value + 'px';
});
// Load settings on page load
loadSettings();
// Save settings
elements.saveSettings.addEventListener('click', saveSettings);
// Reset settings
elements.resetSettings.addEventListener('click', resetSettings);
// Test API connections
elements.testConnection.addEventListener('click', testApiConnections);
// Detect GPU
elements.detectGpu.addEventListener('click', detectGpu);
// Calibration controls
elements.startCalibration.addEventListener('click', startCalibration);
elements.stopCalibration.addEventListener('click', stopCalibration);
async function loadSettings() {
try {
const response = await fetch('http://localhost:3000/api/settings');
const settings = await response.json();
// Populate form fields
elements.openaiKey.value = settings.openaiApiKey || '';
elements.elevenlabsKey.value = settings.elevenlabsApiKey || '';
elements.whisperModel.value = settings.whisperModel || 'base';
elements.gpuAcceleration.value = settings.gpuAcceleration || 'none';
elements.audioThreshold.value = settings.audioThreshold || 0.01;
elements.thresholdValue.textContent = (settings.audioThreshold || 0.01).toFixed(3);
elements.chunkSize.value = settings.chunkSize || 2000;
elements.defaultBackend.value = settings.selectedBackend || 'whisper-local';
elements.textDuration.value = settings.textDuration || 5;
elements.textColor.value = settings.textColor || '#ffffff';
elements.bgColor.value = settings.backgroundColor || '#000000';
elements.fontFamily.value = settings.fontFamily || 'Arial, sans-serif';
elements.fontSize.value = settings.fontSize || 32;
elements.fontSizeValue.textContent = (settings.fontSize || 32) + 'px';
elements.serverPort.value = settings.serverPort || 3000;
// Update ambient noise level display
if (settings.ambientNoiseLevel !== null) {
elements.ambientLevel.textContent = `Current: ${settings.ambientNoiseLevel.toFixed(4)}`;
}
showStatus('Settings loaded successfully', 'success');
} catch (error) {
console.error('Error loading settings:', error);
showStatus('Error loading settings', 'error');
}
}
async function saveSettings() {
try {
const settings = {
openaiApiKey: elements.openaiKey.value.trim(),
elevenlabsApiKey: elements.elevenlabsKey.value.trim(),
whisperModel: elements.whisperModel.value,
gpuAcceleration: elements.gpuAcceleration.value,
audioThreshold: parseFloat(elements.audioThreshold.value),
chunkSize: parseInt(elements.chunkSize.value),
selectedBackend: elements.defaultBackend.value,
textDuration: parseInt(elements.textDuration.value),
textColor: elements.textColor.value,
backgroundColor: elements.bgColor.value,
fontFamily: elements.fontFamily.value,
fontSize: parseInt(elements.fontSize.value),
googleFont: elements.fontFamily.options[elements.fontFamily.selectedIndex].getAttribute('data-google'),
serverPort: parseInt(elements.serverPort.value)
};
const response = await fetch('http://localhost:3000/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
showStatus('Settings saved successfully! Some changes may require a restart.', 'success');
} else {
throw new Error('Failed to save settings');
}
} catch (error) {
console.error('Error saving settings:', error);
showStatus('Error saving settings', 'error');
}
}
async function resetSettings() {
if (!confirm('Are you sure you want to reset all settings to defaults? This cannot be undone.')) {
return;
}
try {
const response = await fetch('http://localhost:3000/api/settings/reset', {
method: 'POST'
});
if (response.ok) {
await loadSettings();
showStatus('Settings reset to defaults', 'success');
} else {
throw new Error('Failed to reset settings');
}
} catch (error) {
console.error('Error resetting settings:', error);
showStatus('Error resetting settings', 'error');
}
}
async function detectGpu() {
showStatus('Detecting GPU...', 'info');
try {
const response = await fetch('http://localhost:3000/api/detect-gpu', {
method: 'POST'
});
const result = await response.json();
let message = 'GPU Detection Results:\n';
message += `NVIDIA CUDA: ${result.cuda ? 'Available' : 'Not found'}\n`;
message += `AMD ROCm: ${result.rocm ? 'Available' : 'Not found'}\n`;
message += `Intel OpenVINO: ${result.openvino ? 'Available' : 'Not found'}\n`;
if (result.recommended) {
message += `\nRecommended: ${result.recommended}`;
elements.gpuAcceleration.value = result.recommended;
}
showStatus(message.replace(/\n/g, '<br>'), 'success');
} catch (error) {
console.error('Error detecting GPU:', error);
showStatus('Error detecting GPU', 'error');
}
}
async function testApiConnections() {
showStatus('Testing API connections...', 'info');
try {
const response = await fetch('http://localhost:3000/api/test-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
openaiApiKey: elements.openaiKey.value.trim(),
elevenlabsApiKey: elements.elevenlabsKey.value.trim()
})
});
const results = await response.json();
let message = 'API Connection Test Results:\n';
if (results.openai) {
message += `✓ OpenAI: ${results.openai.success ? 'Connected' : 'Failed - ' + results.openai.error}\n`;
}
if (results.elevenlabs) {
message += `✓ ElevenLabs: ${results.elevenlabs.success ? 'Connected' : 'Failed - ' + results.elevenlabs.error}\n`;
}
showStatus(message.replace(/\n/g, '<br>'), results.openai?.success || results.elevenlabs?.success ? 'success' : 'error');
} catch (error) {
console.error('Error testing connections:', error);
showStatus('Error testing API connections', 'error');
}
}
async function startCalibration() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
microphone = audioContext.createMediaStreamSource(stream);
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.3;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
microphone.connect(analyser);
isCalibrating = true;
calibrationData = [];
elements.startCalibration.disabled = true;
elements.stopCalibration.disabled = false;
elements.calibrationStatus.textContent = 'Calibrating... Stay quiet for 10 seconds.';
let progress = 0;
const calibrationDuration = 10000; // 10 seconds
const updateInterval = 100; // Update every 100ms
const totalUpdates = calibrationDuration / updateInterval;
calibrationInterval = setInterval(() => {
if (!isCalibrating) return;
analyser.getByteTimeDomainData(dataArray);
// Calculate RMS
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
const sample = (dataArray[i] - 128) / 128;
sum += sample * sample;
}
const rms = Math.sqrt(sum / bufferLength);
calibrationData.push(rms);
progress++;
const progressPercent = (progress / totalUpdates) * 100;
elements.calibrationProgress.style.width = `${progressPercent}%`;
if (progress >= totalUpdates) {
finishCalibration();
}
}, updateInterval);
} catch (error) {
console.error('Error starting calibration:', error);
showStatus('Error accessing microphone for calibration', 'error');
stopCalibration();
}
}
function stopCalibration() {
isCalibrating = false;
if (calibrationInterval) {
clearInterval(calibrationInterval);
calibrationInterval = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
elements.startCalibration.disabled = false;
elements.stopCalibration.disabled = true;
elements.calibrationProgress.style.width = '0%';
elements.calibrationStatus.textContent = 'Calibration stopped.';
}
async function finishCalibration() {
if (calibrationData.length === 0) {
stopCalibration();
return;
}
// Calculate average ambient noise level
const avgLevel = calibrationData.reduce((a, b) => a + b, 0) / calibrationData.length;
// Set threshold slightly above ambient level
const suggestedThreshold = Math.max(avgLevel * 3, 0.005);
try {
const response = await fetch('http://localhost:3000/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ambientNoiseLevel: avgLevel,
audioThreshold: suggestedThreshold
})
});
if (response.ok) {
elements.ambientLevel.textContent = `Current: ${avgLevel.toFixed(4)}`;
elements.audioThreshold.value = suggestedThreshold;
elements.thresholdValue.textContent = suggestedThreshold.toFixed(3);
elements.calibrationStatus.textContent = `Calibration complete! New threshold: ${suggestedThreshold.toFixed(3)}`;
showStatus('Ambient noise calibration completed successfully', 'success');
}
} catch (error) {
console.error('Error saving calibration:', error);
showStatus('Error saving calibration results', 'error');
}
stopCalibration();
}
function showStatus(message, type) {
elements.statusMessage.textContent = message;
elements.statusMessage.innerHTML = message; // Allow HTML for test results
elements.statusMessage.className = `status-${type}`;
elements.statusMessage.style.display = 'block';
// Auto-hide after 5 seconds
setTimeout(() => {
elements.statusMessage.style.display = 'none';
}, 5000);
}
});
// Add status message styles
const style = document.createElement('style');
style.textContent = `
.status-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.status-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
`;
document.head.appendChild(style);
+319
View File
@@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' http://localhost:3000 https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self' 'unsafe-inline' http://localhost:3000; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 ws://localhost:3000">
<title>Settings - Live Transcription</title>
<link rel="stylesheet" href="css/styles.css">
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow-y: auto;
background: #0a0a0a;
}
.settings-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
}
.settings-section {
background: #1a1a1a;
margin-bottom: 20px;
padding: 20px;
border-radius: 8px;
border: 1px solid #333;
}
.settings-section h3 {
color: #fff;
margin-bottom: 15px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.setting-item label {
color: #ccc;
font-size: 14px;
font-weight: 500;
}
.setting-item input,
.setting-item select {
padding: 8px;
border: 1px solid #333;
background: #2a2a2a;
color: #fff;
border-radius: 4px;
}
.setting-item input[type="password"] {
font-family: monospace;
}
.calibration-section {
background: #2a2a1a;
padding: 15px;
border-radius: 4px;
border: 1px solid #444;
}
.calibration-controls {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.calibration-status {
color: #ccc;
font-size: 12px;
margin-top: 5px;
}
.progress-bar {
width: 100%;
height: 20px;
background: #333;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50 0%, #FFC107 50%, #f44336 100%);
width: 0%;
transition: width 0.1s ease;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.back-link {
color: #4CAF50;
text-decoration: none;
margin-bottom: 20px;
display: inline-block;
}
.back-link:hover {
text-decoration: underline;
}
.help-text {
color: #888;
font-size: 12px;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="settings-container">
<button onclick="window.location.href='index.html'" class="btn btn-small" style="margin-bottom: 20px;">← Back to Main</button>
<h1 style="color: #fff; margin-bottom: 30px;">Settings</h1>
<div class="settings-section">
<h3>API Configuration</h3>
<div class="settings-grid">
<div class="setting-item">
<label for="openai-key">OpenAI API Key</label>
<input type="password" id="openai-key" placeholder="Enter your OpenAI API key">
<div class="help-text">Required for remote Whisper transcription</div>
</div>
<div class="setting-item">
<label for="elevenlabs-key">ElevenLabs API Key</label>
<input type="password" id="elevenlabs-key" placeholder="Enter your ElevenLabs API key">
<div class="help-text">Required for ElevenLabs Scribe transcription</div>
</div>
</div>
</div>
<div class="settings-section">
<h3>Audio Processing</h3>
<div class="settings-grid">
<div class="setting-item">
<label for="whisper-model">Whisper Model</label>
<select id="whisper-model">
<option value="tiny">Tiny (fastest, least accurate)</option>
<option value="base">Base (good balance)</option>
<option value="small">Small (more accurate)</option>
<option value="medium">Medium (high accuracy)</option>
<option value="large">Large (highest accuracy, slowest)</option>
</select>
<div class="help-text">Larger models are more accurate but slower</div>
</div>
<div class="setting-item">
<label for="gpu-acceleration">GPU Acceleration</label>
<select id="gpu-acceleration">
<option value="none">Disabled (CPU only)</option>
<option value="cuda">NVIDIA CUDA</option>
<option value="rocm">AMD ROCm</option>
<option value="openvino">Intel OpenVINO</option>
</select>
<div class="help-text">
CUDA: NVIDIA GPUs<br>
ROCm: AMD GPUs (Linux only)<br>
OpenVINO: Intel Arc/Integrated GPUs
</div>
</div>
<div class="setting-item">
<label for="audio-threshold">Audio Threshold</label>
<input type="range" id="audio-threshold" min="0.001" max="0.1" step="0.001" value="0.01">
<span id="threshold-value">0.01</span>
<div class="help-text">Lower = more sensitive to quiet sounds</div>
</div>
<div class="setting-item">
<label for="chunk-size">Transcription Speed</label>
<select id="chunk-size">
<option value="1000">Very Fast (1s chunks)</option>
<option value="2000">Fast (2s chunks)</option>
<option value="3000">Balanced (3s chunks)</option>
<option value="5000">Quality (5s chunks)</option>
</select>
<div class="help-text">Faster = more responsive but may cut off words</div>
</div>
</div>
<div class="calibration-section">
<h4 style="color: #fff; margin-bottom: 10px;">Ambient Noise Calibration</h4>
<p style="color: #ccc; font-size: 14px;">Calibrate the app to learn your ambient noise level for better threshold detection.</p>
<div class="progress-bar">
<div id="calibration-progress" class="progress-fill"></div>
</div>
<div class="calibration-controls">
<button id="start-calibration" class="btn btn-primary">Start Calibration</button>
<button id="stop-calibration" class="btn btn-secondary" disabled>Stop</button>
<span id="ambient-level">Current: Not calibrated</span>
</div>
<div id="calibration-status" class="calibration-status">
Click "Start Calibration" and stay quiet for 10 seconds to measure ambient noise.
</div>
</div>
</div>
<div class="settings-section">
<h3>Display Settings</h3>
<div class="settings-grid">
<div class="setting-item">
<label for="default-backend">Default Backend</label>
<select id="default-backend">
<option value="whisper-local">Whisper (Local)</option>
<option value="whisper-remote">Whisper (Remote)</option>
<option value="elevenlabs">ElevenLabs (Scribe)</option>
</select>
</div>
<div class="setting-item">
<label for="text-duration">Text Duration (seconds)</label>
<input type="number" id="text-duration" min="1" max="60" value="5">
</div>
<div class="setting-item">
<label for="text-color">Text Color</label>
<input type="color" id="text-color" value="#ffffff">
</div>
<div class="setting-item">
<label for="bg-color">Background Color</label>
<input type="color" id="bg-color" value="#000000">
</div>
<div class="setting-item">
<label for="font-family">Font Family</label>
<select id="font-family">
<optgroup label="System Fonts">
<option value="Arial, sans-serif">Arial</option>
<option value="'Times New Roman', serif">Times New Roman</option>
<option value="'Courier New', monospace">Courier New</option>
<option value="Georgia, serif">Georgia</option>
<option value="Verdana, sans-serif">Verdana</option>
</optgroup>
<optgroup label="Google Fonts">
<option value="'Roboto', sans-serif" data-google="Roboto">Roboto</option>
<option value="'Open Sans', sans-serif" data-google="Open+Sans">Open Sans</option>
<option value="'Lato', sans-serif" data-google="Lato">Lato</option>
<option value="'Montserrat', sans-serif" data-google="Montserrat">Montserrat</option>
<option value="'Poppins', sans-serif" data-google="Poppins">Poppins</option>
</optgroup>
</select>
</div>
<div class="setting-item">
<label for="font-size">Font Size</label>
<input type="range" id="font-size" min="16" max="72" value="32">
<span id="font-size-value">32px</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Server Settings</h3>
<div class="settings-grid">
<div class="setting-item">
<label for="server-port">Server Port</label>
<input type="number" id="server-port" min="1000" max="65535" value="3000">
<div class="help-text">Restart required after changing</div>
</div>
</div>
</div>
<div class="button-group">
<button id="save-settings" class="btn btn-success">Save Settings</button>
<button id="reset-settings" class="btn btn-danger">Reset to Defaults</button>
<button id="test-connection" class="btn btn-primary">Test API Connections</button>
<button id="detect-gpu" class="btn btn-primary">Detect GPU</button>
</div>
<div id="status-message" style="margin-top: 20px; padding: 10px; border-radius: 4px; display: none;"></div>
</div>
<script src="js/settings.js"></script>
</body>
</html>