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/index.html | 336 +++++++++++++++++------------- ui/index.js | 382 +++++++++++++++++++++++++++++----- ui/package.json | 76 ++++++- ui/preload.js | 7 +- ui/renderer.js | 564 +++++++++++++++++++++++++++++++------------------- ui/src/components.css | 8 + 6 files changed, 964 insertions(+), 409 deletions(-) (limited to 'ui') diff --git a/ui/index.html b/ui/index.html index b06e56b..90f78c1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -10,179 +10,229 @@
-
-
- -
-
-
- - -
-
- - -
-
- - -
-
-
- - - - - -
-
- -
diff --git a/ui/index.js b/ui/index.js index a056156..2420ece 100644 --- a/ui/index.js +++ b/ui/index.js @@ -3,6 +3,7 @@ const path = require('node:path'); const fs = require('node:fs').promises; const yaml = require('js-yaml'); const { spawn } = require('child_process'); +const https = require('https'); const APP_ROOT = path.join(__dirname, '..'); const CONFIG_PATH = path.join(APP_ROOT, 'config.yaml'); @@ -10,6 +11,20 @@ const CONFIG_PATH = path.join(APP_ROOT, 'config.yaml'); let mainWindow; let runningProcess = null; // Track the running Python process +// Required DLL files for CUDA/cuDNN support +const REQUIRED_DLLS = [ + 'cublas64_12.dll', + 'cublasLt64_12.dll', + 'cudnn64_9.dll', + 'cudnn_adv64_9.dll', + 'cudnn_cnn64_9.dll', + 'cudnn_engines_precompiled64_9.dll', + 'cudnn_engines_runtime_compiled64_9.dll', + 'cudnn_graph64_9.dll', + 'cudnn_heuristic64_9.dll', + 'cudnn_ops64_9.dll' +]; + // Helper function to get the correct Python executable from venv function getVenvPython() { const venvPath = path.join(APP_ROOT, 'venv'); @@ -24,6 +39,78 @@ function sendPythonOutput(message, type = 'stdout') { } } +// Helper function to create environment with DLL path +function createPythonEnvironment() { + const dllPath = path.join(APP_ROOT, 'dll'); + const binPath = path.join(APP_ROOT, 'bin'); + const env = { ...process.env }; + env.PATH = `${dllPath};${binPath};${env.PATH}`; + env.HF_HUB_DISABLE_SYMLINKS_WARNING = '1'; + return env; +} + +// Helper function to download a file from URL +function downloadFile(url, outputPath) { + return new Promise((resolve, reject) => { + const file = require('fs').createWriteStream(outputPath); + + const request = https.get(url, (response) => { + if (response.statusCode === 200) { + response.pipe(file); + + file.on('finish', () => { + file.close(); + resolve(); + }); + + file.on('error', (err) => { + fs.unlink(outputPath).catch(() => {}); // Clean up on error + reject(err); + }); + } else { + file.close(); + fs.unlink(outputPath).catch(() => {}); // Clean up on error + reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + } + }); + + request.on('error', (err) => { + file.close(); + fs.unlink(outputPath).catch(() => {}); // Clean up on error + reject(err); + }); + }); +} + +// Helper function to setup process event handlers +function setupProcessHandlers(process) { + process.stdout.on('data', (data) => { + const text = data.toString(); + sendPythonOutput(text.trimEnd(), 'stdout'); + }); + + process.stderr.on('data', (data) => { + const text = data.toString(); + sendPythonOutput(text.trimEnd(), 'stderr'); + }); + + process.on('error', (error) => { + sendPythonOutput(`Process error: ${error.message}`, 'stderr'); + runningProcess = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('process-stopped'); + } + }); + + process.on('close', (code) => { + sendPythonOutput(`Process exited with code ${code}`, 'info'); + runningProcess = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('process-stopped'); + } + }); +} + // Helper function to execute Python commands using venv function executePythonCommand(args, options = {}) { return new Promise((resolve, reject) => { @@ -31,14 +118,9 @@ function executePythonCommand(args, options = {}) { const commandStr = `${path.basename(pythonPath)} ${args.join(' ')}`; sendPythonOutput(`> ${commandStr}`, 'info'); - // Add dll directory to PATH for Windows DLL loading - const dllPath = path.join(APP_ROOT, 'dll'); - const env = { ...process.env }; - env.PATH = `${dllPath};${env.PATH}`; - const spawnOptions = { ...options, - env + env: createPythonEnvironment() }; const pythonProcess = spawn(pythonPath, args, spawnOptions); @@ -78,6 +160,7 @@ function createWindow () { mainWindow = new BrowserWindow({ width: 1000, height: 800, + icon: path.join(APP_ROOT, 'Images', 'favicon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -93,6 +176,7 @@ const DEFAULT_CONFIG = { compute_type: 'float16', enable_debug_mode: 0, enable_previews: 1, + user_prompt: 'Use proper punctuation and grammar. Prefer spelled out numbers like one, eleven, twenty, etc.', save_audio: 0, language: 'english', gpu_idx: 0, @@ -117,11 +201,11 @@ ipcMain.handle('load-config', async () => { } catch (error) { if (error.code === 'ENOENT') { // Config file doesn't exist, create it with defaults - console.log('Config file not found, creating with defaults...'); + console.error('Config file not found, creating with defaults...'); try { const yamlContent = yaml.dump(DEFAULT_CONFIG, { lineWidth: -1 }); await fs.writeFile(CONFIG_PATH, yamlContent, 'utf8'); - console.log('Created config.yaml with default values'); + console.error('Created config.yaml with default values'); return DEFAULT_CONFIG; } catch (writeError) { console.error('Error creating default config:', writeError); @@ -145,21 +229,138 @@ ipcMain.handle('save-config', async (event, config) => { } }); -ipcMain.handle('restart-app', () => { - app.relaunch(); - app.exit(); +ipcMain.handle('reset-config', async () => { + try { + // Check if the file exists first + try { + await fs.access(CONFIG_PATH); + // File exists, delete it + await fs.unlink(CONFIG_PATH); + console.error('Config file deleted successfully'); + return { success: true, message: 'Configuration reset to defaults' }; + } catch (error) { + if (error.code === 'ENOENT') { + // Config file doesn't exist, that's fine + return { success: true, message: 'Configuration already at defaults' }; + } + throw error; + } + } catch (error) { + console.error('Error resetting config:', error); + throw new Error(`Failed to reset configuration: ${error.message}`); + } }); -ipcMain.handle('install-requirements', async (event) => { +// Generic function to ensure required files are present +async function ensureRequiredFiles(config) { + const { + directoryName, + requiredFiles, + downloadBaseUrl, + resourceType + } = config; + + const targetPath = path.join(APP_ROOT, directoryName); + + try { + // Check if target directory exists, create it if not + try { + await fs.access(targetPath); + sendPythonOutput(`${resourceType} directory exists`, 'info'); + } catch (error) { + if (error.code === 'ENOENT') { + sendPythonOutput(`Creating ${resourceType} directory...`, 'info'); + await fs.mkdir(targetPath, { recursive: true }); + sendPythonOutput(`${resourceType} directory created`, 'info'); + } else { + throw error; + } + } + + // Check each required file + const missingFiles = []; + for (const fileName of requiredFiles) { + const filePath = path.join(targetPath, fileName); + try { + await fs.access(filePath); + sendPythonOutput(`✓ ${fileName} exists`, 'info'); + } catch (error) { + if (error.code === 'ENOENT') { + missingFiles.push(fileName); + sendPythonOutput(`✗ ${fileName} missing`, 'info'); + } else { + throw error; + } + } + } + + // Download missing files + if (missingFiles.length > 0) { + sendPythonOutput(`Downloading ${missingFiles.length} missing ${resourceType} file${missingFiles.length > 1 ? 's' : ''}...`, 'info'); + + for (const fileName of missingFiles) { + const filePath = path.join(targetPath, fileName); + const downloadUrl = `${downloadBaseUrl}/${fileName}`; + + try { + sendPythonOutput(`Downloading ${fileName}...`, 'info'); + await downloadFile(downloadUrl, filePath); + sendPythonOutput(`✓ Downloaded ${fileName}`, 'info'); + } catch (downloadError) { + sendPythonOutput(`✗ Failed to download ${fileName}: ${downloadError.message}`, 'stderr'); + throw new Error(`Failed to download ${fileName}: ${downloadError.message}`); + } + } + + sendPythonOutput(`All missing ${resourceType} files downloaded successfully`, 'info'); + } else { + sendPythonOutput(`All required ${resourceType} files are present`, 'info'); + } + + return { + success: true, + message: `${resourceType} setup complete. ${missingFiles.length} file${missingFiles.length > 1 ? 's' : ''} downloaded.`, + downloadedFiles: missingFiles + }; + } catch (error) { + console.error(`Error setting up ${resourceType} files:`, error); + throw new Error(`${resourceType} setup failed: ${error.message}`); + } +} + +// Update the install-requirements handler +ipcMain.handle('install-requirements', async () => { const requirementsPath = path.join(APP_ROOT, 'app', 'requirements.txt'); + const venvMarkerPath = path.join(APP_ROOT, '.venv_is_set_up'); try { + // Check if venv is already set up + try { + await fs.access(venvMarkerPath); + sendPythonOutput('Virtual environment already set up, skipping installation', 'info'); + return { success: true, message: 'Virtual environment already set up' }; + } catch (error) { + // Marker doesn't exist, proceed with setup + } + // Check if requirements.txt exists await fs.access(requirementsPath); - const result = await executePythonCommand(['-m', 'pip', 'install', '-r', requirementsPath]); + await executePythonCommand(['-m', 'pip', 'install', '-r', requirementsPath]); + + await ensureRequiredFiles({ + directoryName: 'dll', + requiredFiles: REQUIRED_DLLS, + downloadBaseUrl: 'https://yummers.dev/tastt/dll', + resourceType: 'DLL' + }); + + await fs.mkdir(path.join(APP_ROOT, 'Models'), { recursive: true }); + + await fs.writeFile(venvMarkerPath, new Date().toISOString(), 'utf8'); + sendPythonOutput('Created .venv_is_set_up marker file', 'info'); - return { success: true, message: 'Requirements installed successfully' }; + return { success: true, message: 'Requirements and dependencies installed successfully' }; } catch (error) { console.error('Error installing requirements:', error); if (error.code === 'ENOENT') { @@ -175,7 +376,6 @@ ipcMain.handle('get-microphones', async () => { try { const result = await executePythonCommand([scriptPath]); const microphones = JSON.parse(result.stdout.trim()); - console.log('Successfully retrieved microphones:', microphones); return microphones; } catch (error) { console.error('Failed to get microphones:', error); @@ -183,53 +383,135 @@ ipcMain.handle('get-microphones', async () => { } }); -// Add handlers for starting and stopping the process -ipcMain.handle('start-process', async () => { - if (runningProcess) { - throw new Error('Process is already running'); +// Helper function to safely delete directory contents +async function clearDirectory(dirPath, dirName) { + try { + await fs.access(dirPath); + sendPythonOutput(`Clearing ${dirName} directory...`, 'info'); + + const files = await fs.readdir(dirPath); + let deletedCount = 0; + + for (const file of files) { + const filePath = path.join(dirPath, file); + + try { + await fs.rm(filePath, { recursive: true, force: true }); + sendPythonOutput(`✗ Deleted file ${file}`, 'info'); + + deletedCount++; + } catch (deleteError) { + sendPythonOutput(`Warning: Could not delete ${file}: ${deleteError.message}`, 'stderr'); + // Continue with other files even if one fails + } + } + + sendPythonOutput(`${dirName} directory cleared`, 'info'); + return deletedCount; + } catch (error) { + if (error.code === 'ENOENT') { + sendPythonOutput(`${dirName} directory doesn't exist, skipping`, 'info'); + return 0; + } else { + sendPythonOutput(`Error clearing ${dirName} directory: ${error.message}`, 'stderr'); + throw error; + } } +} - const scriptPath = path.join(APP_ROOT, 'app', 'hi.py'); - const configPath = CONFIG_PATH; +ipcMain.handle('reset-venv', async () => { + const venvMarkerPath = path.join(APP_ROOT, '.venv_is_set_up'); try { - const pythonPath = getVenvPython(); - const args = [scriptPath, '--config', configPath]; + sendPythonOutput('Starting virtual environment reset...', 'info'); - sendPythonOutput(`Starting process: ${path.basename(pythonPath)} ${args.join(' ')}`, 'info'); + // Delete the venv marker file first + try { + await fs.unlink(venvMarkerPath); + sendPythonOutput('Deleted .venv_is_set_up marker file', 'info'); + } catch (error) { + if (error.code !== 'ENOENT') { + sendPythonOutput(`Warning: Could not delete marker file: ${error.message}`, 'stderr'); + } + } + + // Get list of installed packages + sendPythonOutput('Getting list of installed packages...', 'info'); + const freezeResult = await executePythonCommand(['-m', 'pip', 'freeze']); + const installedPackages = freezeResult.stdout.trim(); + + let uninstalledPackages = []; + + if (!installedPackages) { + sendPythonOutput('No packages found to uninstall', 'info'); + } else { + // Parse package names and filter out core packages + const packageLines = installedPackages.split('\n').filter(line => line.trim()); + const packageNames = packageLines + .map(line => line.split('==')[0].trim()) + .filter(name => name && !name.startsWith('#')); + + const corePackages = ['pip', 'setuptools', 'wheel']; + const packagesToUninstall = packageNames.filter(name => !corePackages.includes(name.toLowerCase())); + + if (packagesToUninstall.length === 0) { + sendPythonOutput('Only core packages found, nothing to uninstall', 'info'); + } else { + sendPythonOutput(`Uninstalling ${packagesToUninstall.length} packages...`, 'info'); + + const uninstallArgs = ['-m', 'pip', 'uninstall', '-y', ...packagesToUninstall]; + await executePythonCommand(uninstallArgs); + uninstalledPackages = packagesToUninstall; + } + } + + // Clear downloaded files + sendPythonOutput('Clearing downloaded files...', 'info'); - // Add dll directory to PATH for Windows DLL loading const dllPath = path.join(APP_ROOT, 'dll'); - const env = { ...process.env }; - env.PATH = `${dllPath};${env.PATH}`; + const modelsPath = path.join(APP_ROOT, 'Models'); + const binPath = path.join(APP_ROOT, 'bin'); - runningProcess = spawn(pythonPath, args, { env }); + const deletedDlls = await clearDirectory(dllPath, 'DLL'); + const deletedModels = await clearDirectory(modelsPath, 'Models'); + const deletedBins = await clearDirectory(binPath, 'Binary'); - runningProcess.stdout.on('data', (data) => { - const text = data.toString(); - sendPythonOutput(text.trimEnd(), 'stdout'); - }); + const totalDeletedFiles = deletedDlls + deletedModels + deletedBins; - runningProcess.stderr.on('data', (data) => { - const text = data.toString(); - sendPythonOutput(text.trimEnd(), 'stderr'); - }); + sendPythonOutput('Virtual environment reset successfully!', 'info'); - runningProcess.on('error', (error) => { - sendPythonOutput(`Process error: ${error.message}`, 'stderr'); - runningProcess = null; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('process-stopped'); + return { + success: true, + message: `Virtual environment reset complete. Uninstalled ${uninstalledPackages.length} packages and deleted ${totalDeletedFiles} downloaded files.`, + uninstalledPackages, + deletedFiles: { + dlls: deletedDlls, + models: deletedModels, + binaries: deletedBins, + total: totalDeletedFiles } - }); + }; + } catch (error) { + console.error('Error resetting virtual environment:', error); + throw new Error(`Virtual environment reset failed: ${error.message}`); + } +}); + +// Add handlers for starting and stopping the process +ipcMain.handle('start-process', async () => { + if (runningProcess) { + throw new Error('Process is already running'); + } + + const scriptPath = path.join(APP_ROOT, 'app', 'hi.py'); + const args = [scriptPath, '--config', CONFIG_PATH]; + + try { + const pythonPath = getVenvPython(); + sendPythonOutput(`Starting process: ${path.basename(pythonPath)} ${args.join(' ')}`, 'info'); - runningProcess.on('close', (code) => { - sendPythonOutput(`Process exited with code ${code}`, 'info'); - runningProcess = null; - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('process-stopped'); - } - }); + runningProcess = spawn(pythonPath, args, { env: createPythonEnvironment() }); + setupProcessHandlers(runningProcess); return { success: true }; } catch (error) { @@ -243,7 +525,7 @@ ipcMain.handle('stop-process', async () => { throw new Error('No process is running'); } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let forcefullyKilled = false; // Set up a timeout to force kill after 10 seconds diff --git a/ui/package.json b/ui/package.json index fee2d67..3a58298 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,12 +3,85 @@ "version": "1.0.0", "description": "Speech-to-text tool for VRChat", "main": "index.js", + "homepage": "./", "scripts": { "start": "npm run build:css && electron .", "build:css": "tailwindcss -i ./src/components.css -o ./build/output.css", "watch:css": "tailwindcss -i ./src/components.css -o ./build/output.css --watch", "dev": "concurrently \"npm run watch:css\" \"electron .\"", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "dist": "npm run build:css && electron-builder", + "dist:win": "npm run build:css && electron-builder --win", + "dist:portable": "npm run build:css && electron-builder --win portable", + "dist:zip": "npm run build:css && electron-builder --win zip" + }, + "build": { + "appId": "com.yum_food.tastt", + "productName": "TaSTT", + "directories": { + "output": "dist" + }, + "files": [ + "**/*", + "!dist/**/*", + "!src/**/*", + "!node_modules/**/{CHANGELOG.md,README.md,README,readme.md,readme}", + "!node_modules/**/{test,__tests__,tests,powered-test,example,examples}", + "!node_modules/**/*.d.ts", + "!node_modules/.bin", + "!.git/**/*", + "!.gitignore" + ], + "extraResources": [ + { + "from": "../app", + "to": "app", + "filter": [ + "**/*.py", + "requirements.txt", + "!**/__pycache__/**/*" + ] + }, + { + "from": "../config.yaml", + "to": "config.yaml" + }, + { + "from": "../dll", + "to": "dll", + "filter": ["**/*"] + }, + { + "from": "../Images", + "to": "Images", + "filter": ["**/*"] + }, + { + "from": "../bin", + "to": "bin", + "filter": ["**/*"] + } + ], + "win": { + "icon": "../Images/logo.png", + "target": [ + { + "target": "portable", + "arch": ["x64"] + }, + { + "target": "zip", + "arch": ["x64"] + } + ] + }, + "portable": { + "artifactName": "${productName}-${version}-portable.exe" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } }, "keywords": [], "author": "yum_food", @@ -22,6 +95,7 @@ "concurrently": "^9.1.2", "cross-env": "^7.0.3", "electron": "^36.3.2", + "electron-builder": "^25.1.8", "postcss": "^8.5.4", "tailwindcss": "^3.4.17", "vite": "^6.3.5", diff --git a/ui/preload.js b/ui/preload.js index e6c0623..35cc8d6 100644 --- a/ui/preload.js +++ b/ui/preload.js @@ -3,14 +3,13 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { loadConfig: () => ipcRenderer.invoke('load-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config), - restartApp: () => ipcRenderer.invoke('restart-app'), + resetConfig: () => ipcRenderer.invoke('reset-config'), getMicrophones: () => ipcRenderer.invoke('get-microphones'), installRequirements: () => ipcRenderer.invoke('install-requirements'), + resetVenv: () => ipcRenderer.invoke('reset-venv'), startProcess: () => ipcRenderer.invoke('start-process'), stopProcess: () => ipcRenderer.invoke('stop-process'), onPythonOutput: (callback) => ipcRenderer.on('python-output', (event, data) => callback(data)), - onProcessStopped: (callback) => ipcRenderer.on('process-stopped', (event) => callback()) + onProcessStopped: (callback) => ipcRenderer.on('process-stopped', () => callback()) }); -console.log('Preload script loaded.'); - 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 diff --git a/ui/src/components.css b/ui/src/components.css index d8d909d..2832e12 100644 --- a/ui/src/components.css +++ b/ui/src/components.css @@ -46,6 +46,14 @@ .btn-red { @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; } + + .btn-purple { + @apply bg-purple-600 text-white hover:bg-purple-700 focus:ring-purple-500; + } + + .btn-orange { + @apply bg-orange-600 text-white hover:bg-orange-700 focus:ring-orange-500; + } } /* Console styling */ -- cgit v1.2.3