From e1b3f638a1ea448de9691f69eb62ebf4c3944c9f Mon Sep 17 00:00:00 2001 From: yum Date: Fri, 30 May 2025 02:50:55 -0700 Subject: More polish - Filters actually get applied now, huge accuracy boost - Use silero-vad python library instead of rolling our own - Expose prompt parameter - Auto setup venv on launch - Clean up python output - Auto acquire all dependencies on launch - Add icon --- ui/renderer.js | 564 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 353 insertions(+), 211 deletions(-) (limited to 'ui/renderer.js') diff --git a/ui/renderer.js b/ui/renderer.js index b3f05a6..201eef6 100644 --- a/ui/renderer.js +++ b/ui/renderer.js @@ -1,99 +1,220 @@ -// Handle status messages +// Configuration and form field mappings +const CONFIG_FIELDS = { + // String fields + compute_type: { type: 'select', default: 'float16' }, + language: { type: 'select', default: 'english' }, + model: { type: 'select', default: 'turbo' }, + microphone: { type: 'number', default: 0 }, + user_prompt: { type: 'text', default: '' }, + + // Number fields + gpu_idx: { type: 'number', default: 0 }, + max_speech_duration_s: { type: 'number', default: 10 }, + min_silence_duration_ms: { type: 'number', default: 250 }, + reset_after_silence_s: { type: 'number', default: 15 }, + transcription_loop_delay_ms: { type: 'number', default: 100 }, + block_width: { type: 'number', default: 2 }, + num_blocks: { type: 'number', default: 40 }, + rows: { type: 'number', default: 10 }, + cols: { type: 'number', default: 24 }, + + // Boolean fields (stored as 1/0) + enable_debug_mode: { type: 'boolean', default: 0 }, + enable_previews: { type: 'boolean', default: 1 }, + save_audio: { type: 'boolean', default: 0 }, + use_cpu: { type: 'boolean', default: 0 } +}; + +// Button management system +class ButtonManager { + constructor() { + this.buttons = { + start: document.getElementById('start-process'), + stop: document.getElementById('stop-process'), + setupVenv: document.getElementById('setup-venv'), + resetVenv: document.getElementById('reset-venv'), + refreshMicrophones: document.getElementById('refresh-microphones') + }; + } + + setState(buttonName, disabled) { + const button = this.buttons[buttonName]; + if (!button) return; + + button.disabled = disabled; + if (disabled) { + button.classList.add('opacity-50', 'cursor-not-allowed'); + } else { + button.classList.remove('opacity-50', 'cursor-not-allowed'); + } + } + + setProcessRunning() { + this.setState('start', true); + this.setState('stop', false); + } + + setProcessStopped() { + this.setState('start', false); + this.setState('stop', true); + } + + async withButtonLoading(buttonName, asyncFn) { + this.setState(buttonName, true); + try { + return await asyncFn(); + } finally { + this.setState(buttonName, false); + } + } +} + +const buttonManager = new ButtonManager(); + +// Add loading overlay management +class LoadingOverlay { + constructor() { + this.overlay = document.getElementById('loading-overlay'); + this.form = document.getElementById('config-form'); + this.messageElement = this.overlay.querySelector('p'); + this.defaultMessage = 'Environment setup underway - please wait.'; + } + + show(message = null) { + this.messageElement.textContent = message || this.defaultMessage; + this.overlay.classList.remove('hidden'); + // Disable all form inputs and buttons in the entire left panel + const leftPanel = this.overlay.parentElement; + const inputs = leftPanel.querySelectorAll('input, select, textarea, button'); + inputs.forEach(input => { + input.disabled = true; + input.classList.add('opacity-50'); + }); + } + + hide() { + this.overlay.classList.add('hidden'); + // Re-enable all form inputs and buttons in the entire left panel + const leftPanel = this.overlay.parentElement; + const inputs = leftPanel.querySelectorAll('input, select, textarea, button'); + inputs.forEach(input => { + input.disabled = false; + input.classList.remove('opacity-50'); + }); + // Reset to default message + this.messageElement.textContent = this.defaultMessage; + } +} + +const loadingOverlay = new LoadingOverlay(); + +// Add a flag to prevent auto-save during programmatic updates +let isSettingValues = false; + +// Handle status messages with better color management function showStatus(message, type = 'info') { const statusEl = document.getElementById('status-message'); statusEl.textContent = message; - statusEl.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'bg-blue-100', 'text-green-800', 'text-red-800', 'text-blue-800'); - - if (type === 'success') { - statusEl.classList.add('bg-green-100', 'text-green-800'); - } else if (type === 'error') { - statusEl.classList.add('bg-red-100', 'text-red-800'); - } else { - statusEl.classList.add('bg-blue-100', 'text-blue-800'); - } + + // Remove all status classes + const statusClasses = ['hidden', 'bg-green-100', 'bg-red-100', 'bg-blue-100', 'text-green-800', 'text-red-800', 'text-blue-800']; + statusEl.classList.remove(...statusClasses); + + // Add appropriate classes based on type + const typeMap = { + success: ['bg-green-100', 'text-green-800'], + error: ['bg-red-100', 'text-red-800'], + info: ['bg-blue-100', 'text-blue-800'] + }; + + statusEl.classList.add(...(typeMap[type] || typeMap.info)); // Also log to console appendToConsole(message, type === 'error' ? 'stderr' : 'info'); - setTimeout(() => { - statusEl.classList.add('hidden'); - }, 5000); + setTimeout(() => statusEl.classList.add('hidden'), 5000); } -// Get form values +// Get form values using field mappings function getFormValues() { - const microphoneValue = document.getElementById('microphone').value; - // Convert to number if it's a numeric string (device index) - const microphoneForConfig = /^\d+$/.test(microphoneValue) ? parseInt(microphoneValue) : microphoneValue; - - return { - compute_type: document.getElementById('compute_type').value, - enable_debug_mode: document.getElementById('enable_debug_mode').checked ? 1 : 0, - enable_previews: document.getElementById('enable_previews').checked ? 1 : 0, - save_audio: document.getElementById('save_audio').checked ? 1 : 0, - language: document.getElementById('language').value, - gpu_idx: parseInt(document.getElementById('gpu_idx').value), - max_speech_duration_s: parseInt(document.getElementById('max_speech_duration_s').value), - min_silence_duration_ms: parseInt(document.getElementById('min_silence_duration_ms').value), - microphone: microphoneForConfig, - model: document.getElementById('model').value, - reset_after_silence_s: parseInt(document.getElementById('reset_after_silence_s').value), - transcription_loop_delay_ms: parseInt(document.getElementById('transcription_loop_delay_ms').value), - use_cpu: document.getElementById('use_cpu').checked ? 1 : 0, - block_width: parseInt(document.getElementById('block_width').value), - num_blocks: parseInt(document.getElementById('num_blocks').value), - rows: parseInt(document.getElementById('rows').value), - cols: parseInt(document.getElementById('cols').value) - }; + const config = {}; + + for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) { + const element = document.getElementById(fieldName); + if (!element) continue; + + switch (fieldConfig.type) { + case 'boolean': + config[fieldName] = element.checked ? 1 : 0; + break; + case 'number': + config[fieldName] = parseInt(element.value) || fieldConfig.default; + break; + case 'text': + config[fieldName] = element.value || fieldConfig.default; + break; + default: + config[fieldName] = element.value || fieldConfig.default; + } + } + + return config; } -// Add a flag to prevent auto-save during programmatic updates -let isSettingValues = false; - -// Set form values +// Set form values using field mappings function setFormValues(config) { isSettingValues = true; // Disable auto-save temporarily - document.getElementById('compute_type').value = config.compute_type || 'int8'; - document.getElementById('enable_debug_mode').checked = config.enable_debug_mode === 1; - document.getElementById('enable_previews').checked = config.enable_previews === 1; - document.getElementById('save_audio').checked = config.save_audio === 1; - document.getElementById('language').value = config.language || 'english'; - document.getElementById('gpu_idx').value = config.gpu_idx || 0; - document.getElementById('max_speech_duration_s').value = config.max_speech_duration_s || 10; - document.getElementById('min_silence_duration_ms').value = config.min_silence_duration_ms || 250; - document.getElementById('microphone').value = config.microphone || 'motu'; - document.getElementById('model').value = config.model || 'turbo'; - document.getElementById('reset_after_silence_s').value = config.reset_after_silence_s || 15; - document.getElementById('transcription_loop_delay_ms').value = config.transcription_loop_delay_ms || 100; - document.getElementById('use_cpu').checked = config.use_cpu === 1; - document.getElementById('block_width').value = config.block_width || 2; - document.getElementById('num_blocks').value = config.num_blocks || 40; - document.getElementById('rows').value = config.rows || 10; - document.getElementById('cols').value = config.cols || 24; + for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) { + const element = document.getElementById(fieldName); + if (!element) continue; + + const value = config[fieldName] ?? fieldConfig.default; + + switch (fieldConfig.type) { + case 'boolean': + element.checked = value === 1; + break; + case 'text': + element.value = value || ''; + break; + default: + element.value = value; + } + } isSettingValues = false; // Re-enable auto-save } -// Toggle advanced settings -document.getElementById('toggle-advanced').addEventListener('click', () => { - const advancedSettings = document.getElementById('advanced-settings'); - const chevron = document.getElementById('chevron'); - - if (advancedSettings.classList.contains('hidden')) { - advancedSettings.classList.remove('hidden'); - chevron.classList.add('rotate-90'); - } else { - advancedSettings.classList.add('hidden'); - chevron.classList.remove('rotate-90'); - } -}); +// Console management +const consoleContent = document.getElementById('console-content'); + +function appendToConsole(message, type = 'stdout') { + const timestamp = new Date().toLocaleTimeString(); + const timestampSpan = document.createElement('span'); + timestampSpan.className = 'console-timestamp'; + timestampSpan.textContent = `[${timestamp}] `; + + const messageSpan = document.createElement('span'); + messageSpan.className = `console-${type}`; + messageSpan.textContent = message; + + const lineDiv = document.createElement('div'); + lineDiv.appendChild(timestampSpan); + lineDiv.appendChild(messageSpan); + + consoleContent.appendChild(lineDiv); + + // Auto-scroll to bottom + const pythonConsole = document.getElementById('python-console'); + pythonConsole.scrollTop = pythonConsole.scrollHeight; +} -// Simplify button handlers by extracting common patterns +// Async action handler with better error handling async function handleAsyncAction(actionName, actionFn) { try { const result = await actionFn(); - if (result && result.message) { + if (result?.message) { showStatus(result.message, 'success'); } return result; @@ -103,36 +224,12 @@ async function handleAsyncAction(actionName, actionFn) { } } -// Process control buttons -const startButton = document.getElementById('start-process'); -const stopButton = document.getElementById('stop-process'); - -// Helper functions for button state management -function setButtonState(button, disabled) { - button.disabled = disabled; - if (disabled) { - button.classList.add('opacity-50', 'cursor-not-allowed'); - } else { - button.classList.remove('opacity-50', 'cursor-not-allowed'); - } -} - -function setProcessRunningState() { - setButtonState(startButton, true); - setButtonState(stopButton, false); -} - -function setProcessStoppedState() { - setButtonState(startButton, false); - setButtonState(stopButton, true); -} - // Auto-save functionality with debouncing let saveTimeout; -const SAVE_DELAY = 500; // milliseconds +const SAVE_DELAY = 500; async function autoSaveConfig() { - if (isSettingValues) return; // Don't save during programmatic updates + if (isSettingValues) return; clearTimeout(saveTimeout); saveTimeout = setTimeout(async () => { @@ -141,28 +238,19 @@ async function autoSaveConfig() { await window.electronAPI.saveConfig(config); showStatus('Configuration saved', 'success'); - // Check if process is running (stop button is enabled means process is running) - const stopButton = document.getElementById('stop-process'); - - if (!stopButton.disabled) { - // Process is running, restart it with new config + // Restart process if running + if (!buttonManager.buttons.stop.disabled) { appendToConsole('Restarting process with new configuration...', 'info'); try { await window.electronAPI.stopProcess(); - await new Promise(resolve => setTimeout(resolve, 1000)); - await window.electronAPI.startProcess(); - - // Update button states to reflect running process - setProcessRunningState(); - + buttonManager.setProcessRunning(); appendToConsole('Process restarted with new configuration', 'info'); } catch (error) { appendToConsole(`Failed to restart process: ${error.message}`, 'stderr'); - // Process is stopped, update button states - setProcessStoppedState(); + buttonManager.setProcessStopped(); } } } catch (error) { @@ -171,47 +259,32 @@ async function autoSaveConfig() { }, SAVE_DELAY); } -// Add event listeners to all form inputs for auto-save +// Auto-save setup function setupAutoSave() { - // Get all form inputs const form = document.getElementById('config-form'); - const inputs = form.querySelectorAll('input, select'); + const inputs = form.querySelectorAll('input, select, textarea'); - // Add change listener to each input inputs.forEach(input => { - if (input.type === 'checkbox') { - input.addEventListener('change', autoSaveConfig); - } else if (input.type === 'number' || input.type === 'text') { - input.addEventListener('input', autoSaveConfig); - } else if (input.tagName === 'SELECT') { - input.addEventListener('change', autoSaveConfig); - } + const eventType = input.type === 'checkbox' ? 'change' : + (input.type === 'number' || input.type === 'text' || input.tagName === 'TEXTAREA') ? 'input' : 'change'; + input.addEventListener(eventType, autoSaveConfig); }); } -// Update the setup-venv handler -document.getElementById('setup-venv').addEventListener('click', async () => { - const setupButton = document.getElementById('setup-venv'); - setupButton.disabled = true; - setupButton.classList.add('opacity-50', 'cursor-not-allowed'); - - try { - await handleAsyncAction('Install requirements', async () => { - return await window.electronAPI.installRequirements(); - }); - // Reload microphones after successful installation - await loadMicrophones(); - } finally { - setupButton.disabled = false; - setupButton.classList.remove('opacity-50', 'cursor-not-allowed'); - } -}); - -// Simplified microphone loading +// Microphone loading async function loadMicrophones() { const microphoneSelect = document.getElementById('microphone'); try { + // Check/install requirements during startup + appendToConsole('Checking virtual environment and requirements...', 'info'); + loadingOverlay.show('Setting up environment - this can take several minutes.'); + try { + await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements()); + } finally { + loadingOverlay.hide(); // Always hide overlay when done + } + appendToConsole('Loading available microphones...', 'info'); const microphones = await window.electronAPI.getMicrophones(); @@ -232,7 +305,7 @@ async function loadMicrophones() { appendToConsole(` - ${mic.name} (Device ${mic.index})`, 'stdout'); }); - // Restore previously selected microphone if possible + // Restore previously selected microphone try { const config = await window.electronAPI.loadConfig(); if (config.microphone) { @@ -248,11 +321,144 @@ async function loadMicrophones() { } } -// Update window load to include auto-save setup +// Event handlers setup +function setupEventHandlers() { + // Advanced settings toggle + document.getElementById('toggle-advanced').addEventListener('click', () => { + const advancedSettings = document.getElementById('advanced-settings'); + const chevron = document.getElementById('chevron'); + + if (advancedSettings.classList.contains('hidden')) { + advancedSettings.classList.remove('hidden'); + chevron.classList.add('rotate-90'); + } else { + advancedSettings.classList.add('hidden'); + chevron.classList.remove('rotate-90'); + } + }); + + // Setup virtual environment + document.getElementById('setup-venv').addEventListener('click', async () => { + loadingOverlay.show('Setting up virtual environment - please wait...'); // Show overlay with custom message + try { + await buttonManager.withButtonLoading('setupVenv', async () => { + await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements()); + }); + } finally { + loadingOverlay.hide(); // Always hide overlay when done + } + }); + + // Reset virtual environment + document.getElementById('reset-venv').addEventListener('click', async () => { + loadingOverlay.show('Resetting virtual environment - please wait...'); // Show overlay with custom message + try { + await buttonManager.withButtonLoading('resetVenv', async () => { + await handleAsyncAction('Reset virtual environment', () => window.electronAPI.resetVenv()); + }); + } finally { + loadingOverlay.hide(); // Always hide overlay when done + } + }); + + // Reset configuration + document.getElementById('reset-config').addEventListener('click', async () => { + const confirmReset = confirm('Are you sure you want to reset all settings to defaults? This cannot be undone.'); + if (!confirmReset) return; + + try { + // Stop process if running + const wasRunning = !buttonManager.buttons.stop.disabled; + if (wasRunning) { + appendToConsole('Stopping process before resetting configuration...', 'info'); + await window.electronAPI.stopProcess(); + buttonManager.setProcessStopped(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Reset configuration + appendToConsole('Resetting configuration to defaults...', 'info'); + const result = await window.electronAPI.resetConfig(); + + // Reload configuration with defaults + const config = await window.electronAPI.loadConfig(); + setFormValues(config); + + showStatus(result.message, 'success'); + appendToConsole('Configuration reset successfully', 'info'); + + // Restart process if it was running + if (wasRunning) { + appendToConsole('Restarting process with default configuration...', 'info'); + await window.electronAPI.startProcess(); + buttonManager.setProcessRunning(); + appendToConsole('Process restarted with default configuration', 'info'); + } + } catch (error) { + showStatus(`Failed to reset configuration: ${error.message}`, 'error'); + appendToConsole(`Failed to reset configuration: ${error.message}`, 'stderr'); + } + }); + + // Refresh microphones + document.getElementById('refresh-microphones').addEventListener('click', async () => { + await buttonManager.withButtonLoading('refreshMicrophones', async () => { + await loadMicrophones(); + }); + }); + + // Start process + document.getElementById('start-process').addEventListener('click', async () => { + buttonManager.setState('start', true); + + try { + // The installRequirements function will now check if venv is set up. + loadingOverlay.show('Verifying environment setup - please wait...'); // Show overlay with custom message + try { + await window.electronAPI.installRequirements(); + appendToConsole('Virtual environment setup checked/completed', 'info'); + } finally { + loadingOverlay.hide(); // Always hide overlay when done + } + + await window.electronAPI.startProcess(); + buttonManager.setProcessRunning(); + appendToConsole('Process started successfully', 'info'); + } catch (error) { + appendToConsole(`Failed to start process: ${error.message}`, 'stderr'); + buttonManager.setState('start', false); + } + }); + + // Stop process + document.getElementById('stop-process').addEventListener('click', async () => { + buttonManager.setState('stop', true); + + try { + await window.electronAPI.stopProcess(); + appendToConsole('Process stop initiated', 'info'); + } catch (error) { + appendToConsole(`Failed to stop process: ${error.message}`, 'stderr'); + buttonManager.setState('stop', false); + } + }); + + // Listen for process stopped event + window.electronAPI.onProcessStopped(() => { + buttonManager.setProcessStopped(); + }); +} + +// Initialize application window.addEventListener('load', async () => { appendToConsole('TaSTT Configuration UI initialized', 'info'); - // Load config first + // Set up Python output listener first so we capture all output + window.electronAPI.onPythonOutput((data) => { + appendToConsole(data.message, data.type); + }); + + // Load configuration try { const config = await window.electronAPI.loadConfig(); setFormValues(config); @@ -264,71 +470,7 @@ window.addEventListener('load', async () => { // Load microphones await loadMicrophones(); - // Set up auto-save after everything is loaded + // Setup event handlers and auto-save + setupEventHandlers(); setupAutoSave(); -}); - -// Console management -const consoleContent = document.getElementById('console-content'); - -function appendToConsole(message, type = 'stdout') { - const timestamp = new Date().toLocaleTimeString(); - const timestampSpan = document.createElement('span'); - timestampSpan.className = 'console-timestamp'; - timestampSpan.textContent = `[${timestamp}] `; - - const messageSpan = document.createElement('span'); - messageSpan.className = `console-${type}`; - messageSpan.textContent = message; - - const lineDiv = document.createElement('div'); - lineDiv.appendChild(timestampSpan); - lineDiv.appendChild(messageSpan); - - consoleContent.appendChild(lineDiv); - - // Auto-scroll to bottom - const pythonConsole = document.getElementById('python-console'); - pythonConsole.scrollTop = pythonConsole.scrollHeight; -} - -// Clear console button -document.getElementById('clear-console').addEventListener('click', () => { - consoleContent.innerHTML = ''; - appendToConsole('Console cleared', 'info'); -}); - -// Listen for Python output -window.electronAPI.onPythonOutput((data) => { - appendToConsole(data.message, data.type); -}); - -document.getElementById('start-process').addEventListener('click', async () => { - setButtonState(startButton, true); - - try { - await window.electronAPI.startProcess(); - setProcessRunningState(); - appendToConsole('Process started successfully', 'info'); - } catch (error) { - appendToConsole(`Failed to start process: ${error.message}`, 'stderr'); - setButtonState(startButton, false); - } -}); - -document.getElementById('stop-process').addEventListener('click', async () => { - setButtonState(stopButton, true); - - try { - const result = await window.electronAPI.stopProcess(); - appendToConsole('Process stop initiated', 'info'); - } catch (error) { - appendToConsole(`Failed to stop process: ${error.message}`, 'stderr'); - setButtonState(stopButton, false); - } -}); - -// Listen for process stopped event -window.electronAPI.onProcessStopped(() => { - setProcessStoppedState(); }); \ No newline at end of file -- cgit v1.2.3