diff options
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/.gitignore | 5 | ||||
| -rw-r--r-- | ui/build_scripts/setup-empty-venv.js | 25 | ||||
| -rw-r--r-- | ui/config-schema.js | 57 | ||||
| -rw-r--r-- | ui/index.html | 349 | ||||
| -rw-r--r-- | ui/index.js | 616 | ||||
| -rw-r--r-- | ui/package.json | 118 | ||||
| -rw-r--r-- | ui/postcss.config.js | 6 | ||||
| -rw-r--r-- | ui/preload.js | 17 | ||||
| -rw-r--r-- | ui/renderer.js | 531 | ||||
| -rw-r--r-- | ui/src/components.css | 122 | ||||
| -rw-r--r-- | ui/src/input.css | 3 | ||||
| -rw-r--r-- | ui/tailwind.config.js | 13 |
12 files changed, 1862 insertions, 0 deletions
diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..c1dbe3c --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +build +node_modules +package-lock.json +output.css +dist diff --git a/ui/build_scripts/setup-empty-venv.js b/ui/build_scripts/setup-empty-venv.js new file mode 100644 index 0000000..0691a51 --- /dev/null +++ b/ui/build_scripts/setup-empty-venv.js @@ -0,0 +1,25 @@ +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const projectRoot = path.join(__dirname, '..', '..'); +const venvPath = path.join(projectRoot, 'venv_clean'); +const dllPath = path.join(projectRoot, 'dll_empty'); + +console.log('Creating empty virtual environment and dll directory...'); + +// Create empty dll directory +if (!fs.existsSync(dllPath)) { + fs.mkdirSync(dllPath, { recursive: true }); + console.log('Created empty dll directory'); +} + +try { + console.log('Creating new venv...'); + execSync(`python -m venv "${venvPath}"`, { stdio: 'inherit' }); + console.log('Empty venv created successfully!'); +} catch (error) { + console.error('Failed to create venv:', error); + process.exit(1); +} + diff --git a/ui/config-schema.js b/ui/config-schema.js new file mode 100644 index 0000000..fb90f3f --- /dev/null +++ b/ui/config-schema.js @@ -0,0 +1,57 @@ +// Shared configuration schema with types and defaults +const CONFIG_SCHEMA = { + // 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: 'Use proper punctuation and grammar. Prefer spelled out numbers like one, eleven, twenty, etc. Mm.' }, + keybind: { type: 'text', default: 'ctrl+alt+x' }, + button_hand: { type: 'select', default: 'right' }, + button_type: { type: 'select', default: 'b' }, + + // Number fields + gpu_idx: { type: 'number', default: 0 }, + max_speech_duration_s: { type: 'number', default: 10 }, + min_speech_duration_ms: { type: 'number', default: 250 }, + min_silence_duration_ms: { type: 'number', default: 100 }, + 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 }, + beam_size: { type: 'number', default: 5 }, + best_of: { type: 'number', default: 5 }, + volume: { type: 'number', default: 30 }, + + // 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 }, + enable_segment_logging: { type: 'boolean', default: 0 }, + use_cpu: { type: 'boolean', default: 0 }, + enable_lowercase_filter: { type: 'boolean', default: 0 }, + enable_uppercase_filter: { type: 'boolean', default: 0 }, + enable_profanity_filter: { type: 'boolean', default: 0 }, + remove_trailing_period: { type: 'boolean', default: 0 }, + reset_on_toggle: { type: 'boolean', default: 0 }, + use_builtin: { type: 'boolean', default: 1 } +}; + +// Helper to extract just the default values +function getDefaultConfig() { + const defaults = {}; + for (const [key, schema] of Object.entries(CONFIG_SCHEMA)) { + defaults[key] = schema.default; + } + return defaults; +} + +// Export for both CommonJS (main process) and ES modules (renderer) +if (typeof module !== 'undefined' && module.exports) { + module.exports = { CONFIG_SCHEMA, getDefaultConfig }; +} else { + window.CONFIG_SCHEMA = CONFIG_SCHEMA; + window.getDefaultConfig = getDefaultConfig; +}
\ No newline at end of file diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..70eaa68 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,349 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"> + <title>TaSTT</title> + <link rel="stylesheet" href="output.css"> +</head> +<body class="bg-gray-100"> + <div class="container-fluid px-6 py-6 h-screen flex flex-col"> + <div class="flex flex-1 gap-6 overflow-hidden"> + <!-- Left Panel: Configuration Form --> + <div class="max-w-96 relative flex flex-col overflow-hidden rounded-lg"> + <!-- Loading Overlay --> + <div id="loading-overlay" class="absolute inset-0 bg-white bg-opacity-75 backdrop-blur-sm z-50 hidden flex items-center justify-center"> + <div class="text-center p-6"> + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> + <p class="text-gray-700 font-medium"></p> + </div> + </div> + + <!-- Scrollable form container --> + <div class="overflow-y-auto flex-1"> + <form id="config-form" class="space-y-6 pr-3"> + <!-- Basic settings (Always Visible) --> + <section class="config-section"> + <div class="grid grid-cols-2 gap-4"> + <div> + <label for="model" class="form-label">Model</label> + <select id="model" class="form-input"> + <option value="tiny">tiny</option> + <option value="base">base</option> + <option value="small">small</option> + <option value="medium">medium</option> + <option value="large">large</option> + <option value="turbo">turbo</option> + </select> + </div> + <div> + <label for="language" class="form-label">Language</label> + <select id="language" class="form-input"> + <option value="english">English</option> + <option value="spanish">Spanish</option> + <option value="french">French</option> + <option value="german">German</option> + <option value="italian">Italian</option> + <option value="portuguese">Portuguese</option> + <option value="russian">Russian</option> + <option value="chinese">Chinese</option> + <option value="japanese">Japanese</option> + <option value="korean">Korean</option> + </select> + </div> + <div class="col-span-2"> + <label for="microphone" class="form-label">Microphone</label> + <div class="flex gap-2"> + <select id="microphone" class="form-input flex-1"> + <option value="">Loading microphones...</option> + </select> + <button type="button" id="refresh-microphones" class="btn btn-gray px-3 py-2 flex items-center" title="Refresh microphone list"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> + </svg> + </button> + </div> + </div> + <div> + <label for="button_hand" class="form-label"> + VR Hand + </label> + <select id="button_hand" class="form-input"> + <option value="left">Left</option> + <option value="right">Right</option> + </select> + </div> + <div> + <label for="button_type" class="form-label"> + VR Button + </label> + <select id="button_type" class="form-input"> + <option value="a">A</option> + <option value="b">B</option> + <option value="thumbstick">Thumbstick</option> + </select> + </div> + <div class="col-span-2"> + <label for="keybind" class="form-label"> + Keyboard Binding + </label> + <input type="text" id="keybind" value="f24" class="form-input" placeholder="f24"> + </div> + </div> + </section> + + <!-- Advanced settings toggle --> + <button type="button" id="toggle-advanced" class="flex items-center gap-2 text-gray-600 hover:text-gray-800 font-medium"> + <svg id="chevron" class="w-5 h-5 transform transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> + </svg> + Advanced Settings + </button> + + <!-- Advanced settings (initially hidden) --> + <div id="advanced-settings" class="hidden space-y-6"> + <!-- Compute Settings --> + <section class="config-section"> + <h2 class="section-title">Compute Settings</h2> + <div class="grid grid-cols-2 gap-4"> + <div> + <label for="compute_type" class="form-label">Compute Type</label> + <select id="compute_type" class="form-input"> + <option value="int8">int8</option> + <option value="float16">float16</option> + <option value="float32">float32</option> + </select> + </div> + <div> + <label for="gpu_idx" class="form-label">GPU Index</label> + <input type="number" id="gpu_idx" min="0" value="0" class="form-input"> + </div> + <div class="col-span-2"> + <label for="use_cpu" class="checkbox-label"> + <input type="checkbox" id="use_cpu" class="mr-2"> + <span class="checkbox-text">Use CPU</span> + </label> + </div> + </div> + </section> + + <!-- Audio Settings --> + <section class="config-section"> + <h2 class="section-title">Voice Activity Detection</h2> + <div class="grid grid-cols-2 gap-4"> + <div> + <label for="max_speech_duration_s" class="form-label">Max Speech Duration (seconds)</label> + <input type="number" id="max_speech_duration_s" min="1" value="10" class="form-input"> + </div> + <div> + <label for="min_speech_duration_ms" class="form-label">Min Speech Duration (ms)</label> + <input type="number" id="min_speech_duration_ms" min="0" value="100" class="form-input"> + </div> + <div> + <label for="min_silence_duration_ms" class="form-label">Min Silence Duration (ms)</label> + <input type="number" id="min_silence_duration_ms" min="0" value="250" class="form-input"> + </div> + <div> + <label for="reset_after_silence_s" class="form-label">Reset After Silence (seconds)</label> + <input type="number" id="reset_after_silence_s" min="1" value="15" class="form-input"> + </div> + </div> + </section> + + <!-- Transcription Settings --> + <section class="config-section"> + <h2 class="section-title">Transcription Settings</h2> + <div> + <label for="user_prompt" class="form-label"> + Prompt + <span class="text-gray-500 text-xs block mt-1" + title="Whisper is given this prompt before transcribing. It helps guide the transcription style. For example, you could improve the spelling of your friends' names with: 'My friends' names are Saoirse, Azariah, and Caoimhe.'"> + (Hover for details) + </span> + </label> + <textarea id="user_prompt" + class="form-input h-20 resize-none" + placeholder="My friends' names are Saoirse, Azariah, and Caoimhe."></textarea> + </div> + <div class="grid grid-cols-2 gap-4 mt-4"> + <div> + <label for="beam_size" class="form-label"> + Beam size + <span class="text-gray-500 text-xs block mt-1" + title="Number of beams for beam search. Higher values may improve accuracy but increase compute time."> + (Search width) + </span> + </label> + <input type="number" id="beam_size" min="1" max="20" value="5" class="form-input"> + </div> + <div> + <label for="best_of" class="form-label"> + Best of + <span class="text-gray-500 text-xs block mt-1" + title="Number of candidates to generate when sampling. The best one will be selected."> + (Sampling candidates) + </span> + </label> + <input type="number" id="best_of" min="1" max="20" value="5" class="form-input"> + </div> + </div> + </section> + + <!-- Performance Settings --> + <section class="config-section"> + <h2 class="section-title">Performance Settings</h2> + <div> + <label for="transcription_loop_delay_ms" class="form-label">Transcription Loop Delay (ms)</label> + <input type="number" id="transcription_loop_delay_ms" min="0" value="100" class="form-input"> + </div> + </section> + + <!-- Debug/Preview Settings --> + <section class="config-section"> + <h2 class="section-title">Debug/Preview Settings</h2> + <div class="space-y-3"> + <label for="enable_debug_mode" class="checkbox-label"> + <input type="checkbox" id="enable_debug_mode" class="mr-2"> + <span class="checkbox-text">Enable Debug Mode</span> + </label> + <label for="enable_previews" class="checkbox-label"> + <input type="checkbox" id="enable_previews" checked class="mr-2"> + <span class="checkbox-text">Enable Previews</span> + </label> + <label for="save_audio" class="checkbox-label"> + <input type="checkbox" id="save_audio" class="mr-2"> + <span class="checkbox-text">Save Audio Segments</span> + </label> + <label for="enable_segment_logging" class="checkbox-label"> + <input type="checkbox" id="enable_segment_logging" class="mr-2"> + <span class="checkbox-text">Log Segment Metadata</span> + </label> + </div> + </section> + + <!-- Text Filters --> + <section class="config-section"> + <h2 class="section-title">Text Filters</h2> + <div class="space-y-3"> + <label for="enable_lowercase_filter" class="checkbox-label"> + <input type="checkbox" id="enable_lowercase_filter" class="mr-2"> + <span class="checkbox-text">Convert to lowercase</span> + </label> + <label for="enable_uppercase_filter" class="checkbox-label"> + <input type="checkbox" id="enable_uppercase_filter" class="mr-2"> + <span class="checkbox-text">Convert to uppercase</span> + </label> + <label for="enable_profanity_filter" class="checkbox-label"> + <input type="checkbox" id="enable_profanity_filter" class="mr-2"> + <span class="checkbox-text">Filter profanity</span> + </label> + <label for="remove_trailing_period" class="checkbox-label"> + <input type="checkbox" id="remove_trailing_period" class="mr-2"> + <span class="checkbox-text">Remove trailing period</span> + </label> + </div> + </section> + + <!-- Input Settings --> + <section class="config-section"> + <h2 class="section-title">Input Settings</h2> + <div class="space-y-4"> + <label for="reset_on_toggle" class="checkbox-label"> + <input type="checkbox" id="reset_on_toggle" class="mr-2"> + <span class="checkbox-text">Reset transcript on toggle</span> + </label> + <div> + <label for="volume" class="form-label"> + Local Beep Volume + <span id="volume-display" class="text-gray-500 text-sm ml-2">30%</span> + </label> + <input type="range" id="volume" min="0" max="100" step="10" value="30" class="form-input w-full"> + </div> + </div> + </section> + + <!-- Display Settings --> + <section class="config-section"> + <h2 class="section-title">Custom Chatbox Settings</h2> + <div class="mb-4"> + <label for="use_builtin" class="checkbox-label"> + <input type="checkbox" id="use_builtin" class="mr-2"> + <span class="checkbox-text">Use built-in VRChat chatbox</span> + </label> + </div> + <div class="grid grid-cols-2 gap-4"> + <div> + <label for="block_width" class="form-label">Block Width</label> + <input type="number" id="block_width" min="1" value="2" class="form-input"> + </div> + <div> + <label for="num_blocks" class="form-label">Number of Blocks</label> + <input type="number" id="num_blocks" min="1" value="40" class="form-input"> + </div> + <div> + <label for="rows" class="form-label">Rows</label> + <input type="number" id="rows" min="1" value="10" class="form-input"> + </div> + <div> + <label for="cols" class="form-label">Columns</label> + <input type="number" id="cols" min="1" value="24" class="form-input"> + </div> + </div> + </section> + + <!-- Configuration Settings --> + <section class="config-section"> + <h2 class="section-title">Configuration</h2> + <div> + <button type="button" id="reset-config" class="btn btn-blue w-full"> + Reset Config to Defaults + </button> + </div> + </section> + + <!-- Virtual Environment Settings --> + <section class="config-section"> + <h2 class="section-title">Virtual Environment</h2> + <div class="flex space-x-3"> + <button type="button" id="setup-venv" class="btn btn-blue flex-1"> + Setup venv + </button> + <button type="button" id="reset-venv" class="btn btn-blue flex-1"> + Reset venv + </button> + </div> + </section> + </div> + + <!-- Action Buttons --> + <div class="pb-6"> + <div class="flex space-x-3"> + <button type="button" id="start-process" class="btn btn-green flex-1"> + Start + </button> + <button type="button" id="stop-process" class="btn btn-red flex-1"> + Stop + </button> + </div> + </div> + </form> + + <!-- Status Message --> + <div id="status-message" class="mt-6 p-4 rounded-md hidden"></div> + </div> + </div> + + <!-- Right Panel: Python Console --> + <div class="flex-1 flex flex-col bg-gray-900 rounded-lg overflow-hidden"> + <div id="python-console" class="flex-1 overflow-y-auto p-4 font-mono text-sm"> + <div id="console-content" class="text-gray-300 whitespace-pre-wrap"></div> + </div> + </div> + </div> + </div> + + <script src="config-schema.js"></script> + <script src="renderer.js"></script> +</body> +</html> + diff --git a/ui/index.js b/ui/index.js new file mode 100644 index 0000000..63c633a --- /dev/null +++ b/ui/index.js @@ -0,0 +1,616 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +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 { CONFIG_SCHEMA, getDefaultConfig } = require('./config-schema.js'); + +// Detect if we're running in development or production +const isDev = !app.isPackaged; +const APP_ROOT = isDev + ? path.join(__dirname, '..') // Development: go up from ui/ to project root + : process.resourcesPath; // Production: use Electron's resource path + +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'); + const pythonPath = path.join(venvPath, 'Scripts', 'python.exe'); + return pythonPath; +} + +// Helper function to send Python output to renderer +function sendPythonOutput(message, type = 'stdout') { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('python-output', { message, type }); + } +} + +// 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 with progress +function downloadFile(url, outputPath) { + return new Promise((resolve, reject) => { + const file = require('fs').createWriteStream(outputPath); + const fileName = path.basename(outputPath); + + const request = https.get(url, (response) => { + if (response.statusCode === 200) { + const totalSize = parseInt(response.headers['content-length'], 10); + let downloadedSize = 0; + let lastProgressTime = Date.now(); + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + + // Log progress every 5 seconds + const now = Date.now(); + if (totalSize && (now - lastProgressTime >= 5000)) { + const progress = Math.round((downloadedSize / totalSize) * 100); + const mb = (downloadedSize / 1024 / 1024).toFixed(1); + const totalMb = (totalSize / 1024 / 1024).toFixed(1); + sendPythonOutput(`Downloading ${fileName}: ${mb}/${totalMb} MB (${progress}%)`, 'info'); + lastProgressTime = now; + } + }); + + 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); + }); + }); +} + +function shouldFilterMessage(message) { + // Filter out pydub ffmpeg/avconv warning. It does not actually matter. + if (message.includes("Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work")) { + return true; + } + return false; +} + +// 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(); + if (!shouldFilterMessage(text)) { + 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) => { + const pythonPath = getVenvPython(); + const commandStr = `${path.basename(pythonPath)} ${args.join(' ')}`; + sendPythonOutput(`> ${commandStr}`, 'info'); + + const spawnOptions = { + ...options, + env: createPythonEnvironment() + }; + + const pythonProcess = spawn(pythonPath, args, spawnOptions); + + let stdout = ''; + let stderr = ''; + + pythonProcess.stdout.on('data', (data) => { + const text = data.toString(); + stdout += text; + sendPythonOutput(text.trimEnd(), 'stdout'); + }); + + pythonProcess.stderr.on('data', (data) => { + const text = data.toString(); + stderr += text; + // Filter out specific warning messages + if (!shouldFilterMessage(text)) { + sendPythonOutput(text.trimEnd(), 'stderr'); + } + }); + + pythonProcess.on('error', (error) => { + sendPythonOutput(`Failed to start Python process: ${error.message}`, 'stderr'); + reject({ error: error.message, stdout, stderr }); + }); + + pythonProcess.on('close', (code) => { + if (code !== 0) { + sendPythonOutput(`Process exited with code ${code}`, 'stderr'); + reject({ code, stdout, stderr }); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +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, + nodeIntegration: false + } + }); + + mainWindow.loadFile('index.html'); +} + +// Replace the DEFAULT_CONFIG constant with: +const DEFAULT_CONFIG = getDefaultConfig(); + +// IPC handlers +ipcMain.handle('load-config', async () => { + try { + const fileContent = await fs.readFile(CONFIG_PATH, 'utf8'); + return yaml.load(fileContent); + } catch (error) { + if (error.code === 'ENOENT') { + // Config file doesn't exist, create it 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.error('Created config.yaml with default values'); + return DEFAULT_CONFIG; + } catch (writeError) { + console.error('Error creating default config:', writeError); + // Return defaults even if we can't write the file + return DEFAULT_CONFIG; + } + } + console.error('Error loading config:', error); + throw error; + } +}); + +ipcMain.handle('save-config', async (event, config) => { + try { + const yamlContent = yaml.dump(config, { lineWidth: -1 }); + await fs.writeFile(CONFIG_PATH, yamlContent, 'utf8'); + return { success: true }; + } catch (error) { + console.error('Error saving config:', error); + throw error; + } +}); + +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('deleteVenvIndicatorFile', async () => { + const venvMarkerPath = path.join(APP_ROOT, '.venv_is_set_up'); + try { + await fs.unlink(venvMarkerPath); + return { success: true, message: '.venv_is_set_up deleted successfully.' }; + } catch (error) { + if (error.code === 'ENOENT') { + return { success: true, message: '.venv_is_set_up not found.' }; + } + console.error('Error deleting .venv_is_set_up file:', error); + sendPythonOutput(`Error deleting .venv_is_set_up: ${error.message}`, 'stderr'); + throw error; + } +}); + +// 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); + 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); + + 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 and dependencies installed successfully' }; + } catch (error) { + console.error('Error installing requirements:', error); + if (error.code === 'ENOENT') { + throw new Error('requirements.txt not found'); + } + throw new Error(`Installation failed: ${error.stderr || error.error || 'Unknown error'}`); + } +}); + +ipcMain.handle('get-microphones', async () => { + const scriptPath = path.join(APP_ROOT, 'app', 'list_microphones.py'); + + try { + const result = await executePythonCommand([scriptPath]); + const microphones = JSON.parse(result.stdout.trim()); + return microphones; + } catch (error) { + console.error('Failed to get microphones:', error); + throw new Error(`Failed to get microphones: ${error.stderr || error.error || 'Unknown error'}`); + } +}); + +// 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; + } + } +} + +ipcMain.handle('reset-venv', async () => { + const venvMarkerPath = path.join(APP_ROOT, '.venv_is_set_up'); + + try { + sendPythonOutput('Starting virtual environment reset...', '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'); + + const dllPath = path.join(APP_ROOT, 'dll'); + const modelsPath = path.join(APP_ROOT, 'Models'); + const binPath = path.join(APP_ROOT, 'bin'); + + const deletedDlls = await clearDirectory(dllPath, 'DLL'); + const deletedModels = await clearDirectory(modelsPath, 'Models'); + const deletedBins = await clearDirectory(binPath, 'Binary'); + + const totalDeletedFiles = deletedDlls + deletedModels + deletedBins; + + sendPythonOutput('Virtual environment reset successfully!', 'info'); + + 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 = spawn(pythonPath, args, { env: createPythonEnvironment() }); + setupProcessHandlers(runningProcess); + + return { success: true }; + } catch (error) { + runningProcess = null; + throw error; + } +}); + +ipcMain.handle('stop-process', async () => { + if (!runningProcess) { + sendPythonOutput('No process to stop', 'info'); + return { success: true, forcefullyKilled: false }; + } + + return new Promise((resolve) => { + let forcefullyKilled = false; + + // Set up a timeout to force kill after 10 seconds + const killTimeout = setTimeout(() => { + if (runningProcess) { + sendPythonOutput('Process did not stop gracefully, forcing termination...', 'stderr'); + forcefullyKilled = true; + runningProcess.kill('SIGKILL'); + } + }, 10000); + + // Listen for the process to exit + runningProcess.once('exit', (code, signal) => { + clearTimeout(killTimeout); + runningProcess = null; + + if (forcefullyKilled) { + sendPythonOutput('Process forcefully terminated', 'info'); + } else { + sendPythonOutput('Process stopped gracefully', 'info'); + } + + resolve({ success: true, forcefullyKilled }); + }); + + // Send termination signal + sendPythonOutput('Stopping process gracefully...', 'info'); + runningProcess.kill('SIGTERM'); + }); +}); + +ipcMain.handle('get-process-state', () => { + return { isRunning: runningProcess !== null }; +}); + +// Clean up on app quit +app.on('before-quit', () => { + if (runningProcess) { + runningProcess.kill(); + } +}); + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', function () { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', function () { + app.quit(); +}); + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..d99424c --- /dev/null +++ b/ui/package.json @@ -0,0 +1,118 @@ +{ + "name": "TaSTT", + "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 ./output.css", + "watch:css": "tailwindcss -i ./src/components.css -o ./output.css --watch", + "dev": "concurrently \"npm run watch:css\" \"electron .\"", + "test": "echo \"Error: no test specified\" && exit 1", + "clean:meta": "node -e \"const fs=require('fs');const path=require('path');function deleteMeta(dir){fs.readdirSync(dir).forEach(f=>{const p=path.join(dir,f);if(f.endsWith('.meta'))fs.unlinkSync(p);else if(fs.statSync(p).isDirectory()&&!f.startsWith('.'))deleteMeta(p);})}deleteMeta('./node_modules')\"", + "prebuild": "node build_scripts/setup-empty-venv.js", + "dist": "npm run prebuild && npm run clean:meta && npm run build:css && electron-builder", + "dist:win": "npm run prebuild && npm run clean:meta && npm run build:css && electron-builder --win", + "dist:portable": "npm run prebuild && npm run clean:meta && npm run build:css && electron-builder --win portable", + "dist:zip": "npm run prebuild && npm run clean:meta && 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": "../Images", + "to": "Images", + "filter": ["**/*"] + }, + { + "from": "../bin", + "to": "bin", + "filter": ["**/*"] + }, + { + "from": "../venv_clean", + "to": "venv", + "filter": ["**/*"] + }, + { + "from": "../dll_empty", + "to": "dll", + "filter": ["**/*"] + }, + { + "from": "../Sounds", + "to": "Sounds", + "filter": ["*.wav"] + } + ], + "win": { + "icon": "../Images/favicon.ico", + "target": [ + { + "target": "portable", + "arch": ["x64"] + }, + { + "target": "zip", + "arch": ["x64"] + } + ] + }, + "portable": { + "artifactName": "${productName}-${version}-portable.exe" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + }, + "compression": "normal", + "artifactName": "${productName}-${version}-${arch}.${ext}" + }, + "keywords": [], + "author": "yum_food", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "autoprefixer": "^10.4.21", + "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", + "vue": "^3.5.16" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/preload.js b/ui/preload.js new file mode 100644 index 0000000..6f6e54f --- /dev/null +++ b/ui/preload.js @@ -0,0 +1,17 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + loadConfig: () => ipcRenderer.invoke('load-config'), + saveConfig: (config) => ipcRenderer.invoke('save-config', config), + resetConfig: () => ipcRenderer.invoke('reset-config'), + getMicrophones: () => ipcRenderer.invoke('get-microphones'), + installRequirements: () => ipcRenderer.invoke('install-requirements'), + deleteVenvIndicatorFile: () => ipcRenderer.invoke('deleteVenvIndicatorFile'), + resetVenv: () => ipcRenderer.invoke('reset-venv'), + startProcess: () => ipcRenderer.invoke('start-process'), + stopProcess: () => ipcRenderer.invoke('stop-process'), + getProcessState: () => ipcRenderer.invoke('get-process-state'), + onPythonOutput: (callback) => ipcRenderer.on('python-output', (event, data) => callback(data)), + onProcessStopped: (callback) => ipcRenderer.on('process-stopped', () => callback()) +}); + diff --git a/ui/renderer.js b/ui/renderer.js new file mode 100644 index 0000000..008e0da --- /dev/null +++ b/ui/renderer.js @@ -0,0 +1,531 @@ +// Import configuration schema +const CONFIG_FIELDS = window.CONFIG_SCHEMA; + +// Process state tracking +let isProcessRunning = false; +let buttonManager; +let loadingOverlay; + +// Auto-save functionality with debouncing +let saveTimeout; +const SAVE_DELAY = 500; +let isSettingValues = false; + +// Console management +const consoleContent = document.getElementById('console-content'); +const MAX_CONSOLE_LINES = 512; +let consoleLineCount = 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') + }; + + // Initialize button states - process is not running at startup + this.setProcessStopped(); + } + + setState(buttonName, disabled) { + const button = this.buttons[buttonName]; + if (!button) return; + + button.disabled = disabled; + } + + setProcessRunning() { + this.setState('start', true); + this.setState('stop', false); + isProcessRunning = true; + } + + setProcessStopped() { + this.setState('start', false); + this.setState('stop', true); + isProcessRunning = false; + } + + async withButtonLoading(buttonName, asyncFn) { + this.setState(buttonName, true); + try { + return await asyncFn(); + } finally { + this.setState(buttonName, false); + } + } +} + +// 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.'; + this.originalStates = new Map(); // Track original disabled states + } + + 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 => { + // Store original disabled state before disabling + this.originalStates.set(input, input.disabled); + input.disabled = true; + input.classList.add('opacity-50'); + }); + } + + hide() { + this.overlay.classList.add('hidden'); + // Restore original states of form inputs and buttons + const leftPanel = this.overlay.parentElement; + const inputs = leftPanel.querySelectorAll('input, select, textarea, button'); + inputs.forEach(input => { + // Restore original disabled state + input.disabled = this.originalStates.get(input) || false; + input.classList.remove('opacity-50'); + }); + // Clear the stored states + this.originalStates.clear(); + // Reset to default message + this.messageElement.textContent = this.defaultMessage; + } +} + +// Handle status messages with better color management +function showStatus(message, type = 'info') { + const statusEl = document.getElementById('status-message'); + statusEl.textContent = message; + + // 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); +} + +// Get form values using field mappings +function getFormValues() { + 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': + const numValue = parseInt(element.value); + config[fieldName] = isNaN(numValue) ? fieldConfig.default : numValue; + break; + case 'text': + config[fieldName] = element.value || fieldConfig.default; + break; + default: + config[fieldName] = element.value || fieldConfig.default; + } + } + + return config; +} + +// Set form values using field mappings +function setFormValues(config) { + isSettingValues = true; // Disable auto-save temporarily + + 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; + } + } + + // Handle use_builtin toggle state + const useBuiltin = config.use_builtin === 1; + const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols']; + customChatboxInputs.forEach(inputId => { + const input = document.getElementById(inputId); + if (input) { + input.disabled = useBuiltin; + if (useBuiltin) { + input.classList.add('opacity-50', 'cursor-not-allowed'); + } else { + input.classList.remove('opacity-50', 'cursor-not-allowed'); + } + } + }); + + // Update volume display + if (config.volume !== undefined) { + const volumePercent = Math.round(config.volume); + document.getElementById('volume-display').textContent = `${volumePercent}%`; + } + + isSettingValues = false; // Re-enable auto-save +} + +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); + consoleLineCount++; + + // Remove old lines if we exceed the limit + if (consoleLineCount > MAX_CONSOLE_LINES) { + // Calculate how many lines to remove (remove 10% to avoid frequent trimming) + const linesToRemove = Math.floor(MAX_CONSOLE_LINES * 0.1); + + // Remove the oldest lines + for (let i = 0; i < linesToRemove; i++) { + if (consoleContent.firstChild) { + consoleContent.removeChild(consoleContent.firstChild); + } + } + + consoleLineCount -= linesToRemove; + + // Add a notice that lines were trimmed + const trimNotice = document.createElement('div'); + trimNotice.className = 'console-info'; + trimNotice.innerHTML = '<span class="console-timestamp">[System] </span><span class="console-info">... older lines removed to maintain performance ...</span>'; + consoleContent.insertBefore(trimNotice, consoleContent.firstChild); + } + + // Auto-scroll to bottom + const pythonConsole = document.getElementById('python-console'); + pythonConsole.scrollTop = pythonConsole.scrollHeight; +} + +// Async action handler with better error handling +async function handleAsyncAction(actionName, actionFn) { + try { + const result = await actionFn(); + if (result?.message) { + showStatus(result.message, 'success'); + } + return result; + } catch (error) { + showStatus(`${actionName} failed: ${error.message}`, 'error'); + throw error; + } +} + +async function autoSaveConfig() { + if (isSettingValues) return; + + clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + try { + const config = getFormValues(); + await window.electronAPI.saveConfig(config); + showStatus('Configuration saved', 'success'); + + // Restart process if running + if (isProcessRunning) { + appendToConsole('Restarting process with new configuration...', 'info'); + + try { + await window.electronAPI.stopProcess(); + await new Promise(resolve => setTimeout(resolve, 1000)); + await window.electronAPI.startProcess(); + buttonManager.setProcessRunning(); + appendToConsole('Process restarted with new configuration', 'info'); + } catch (error) { + appendToConsole(`Failed to restart process: ${error.message}`, 'stderr'); + buttonManager.setProcessStopped(); + } + } + } catch (error) { + showStatus(`Failed to save configuration: ${error.message}`, 'error'); + } + }, SAVE_DELAY); +} + +// Auto-save setup +function setupAutoSave() { + const form = document.getElementById('config-form'); + const inputs = form.querySelectorAll('input, select, textarea'); + + inputs.forEach(input => { + const eventType = input.type === 'checkbox' ? 'change' : + (input.type === 'number' || input.type === 'text' || input.tagName === 'TEXTAREA') ? 'input' : 'change'; + input.addEventListener(eventType, autoSaveConfig); + }); +} + +// 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(); + + microphoneSelect.innerHTML = ''; + + if (microphones.length === 0) { + microphoneSelect.innerHTML = '<option value="" disabled>No microphones found</option>'; + appendToConsole('No microphones found', 'stderr'); + return; + } + + appendToConsole(`Found ${microphones.length} microphone(s)`, 'info'); + microphones.forEach(mic => { + const option = document.createElement('option'); + option.value = mic.index.toString(); + option.textContent = mic.name; + microphoneSelect.appendChild(option); + appendToConsole(` - ${mic.name} (Device ${mic.index})`, 'stdout'); + }); + + // Restore previously selected microphone + try { + const config = await window.electronAPI.loadConfig(); + if (config.microphone) { + microphoneSelect.value = config.microphone; + } + } catch (error) { + // Ignore config load errors here + } + + } catch (error) { + appendToConsole(`Failed to load microphones: ${error.message}`, 'stderr'); + microphoneSelect.innerHTML = '<option value="" disabled>Error loading microphones</option>'; + } +} + +// 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'); + } + }); + + // Use builtin chatbox toggle + document.getElementById('use_builtin').addEventListener('change', (e) => { + const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols']; + const isBuiltin = e.target.checked; + + customChatboxInputs.forEach(inputId => { + const input = document.getElementById(inputId); + if (input) { + input.disabled = isBuiltin; + if (isBuiltin) { + input.classList.add('opacity-50', 'cursor-not-allowed'); + } else { + input.classList.remove('opacity-50', 'cursor-not-allowed'); + } + } + }); + }); + + // Volume slider update + document.getElementById('volume').addEventListener('input', (e) => { + const volumePercent = Math.round(e.target.value); + document.getElementById('volume-display').textContent = `${volumePercent}%`; + }); + + // 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 window.electronAPI.deleteVenvIndicatorFile(); + 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 = isProcessRunning; + 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'); + + loadingOverlay = new LoadingOverlay(); + buttonManager = new ButtonManager(); + + // 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); + appendToConsole('Configuration loaded', 'info'); + } catch (error) { + appendToConsole(`Failed to load configuration: ${error.message}`, 'stderr'); + } + + // Load microphones + await loadMicrophones(); + + // Setup event handlers and auto-save + setupEventHandlers(); + setupAutoSave(); +}); diff --git a/ui/src/components.css b/ui/src/components.css new file mode 100644 index 0000000..2832e12 --- /dev/null +++ b/ui/src/components.css @@ -0,0 +1,122 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .config-section { + @apply bg-white rounded-lg shadow-md p-6; + } + + .section-title { + @apply text-xl font-semibold text-gray-700 mb-4; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-2; + } + + .form-input { + @apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm; + } + + .checkbox-label { + @apply flex items-center cursor-pointer hover:bg-gray-50 p-2 rounded; + } + + .checkbox-text { + @apply text-sm text-gray-700; + } + + .btn { + @apply px-4 py-2 font-medium text-sm rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2; + } + + .btn-blue { + @apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500; + } + + .btn-green { + @apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500; + } + + .btn-gray { + @apply bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500; + } + + .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 */ +#python-console { + background-color: #1a1a1a; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + line-height: 1.4; +} + +#console-content { + word-wrap: break-word; +} + +/* Console text colors */ +.console-stdout { + color: #a8cc8c; +} + +.console-stderr { + color: #e88388; +} + +.console-info { + color: #66c2cd; +} + +.console-timestamp { + color: #6c7986; + font-size: 0.875rem; +} + +/* Ensure full height layout */ +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +.container-fluid { + max-width: 100%; + height: 100vh; +} + +/* Scrollbar styling for console */ +#python-console::-webkit-scrollbar { + width: 8px; +} + +#python-console::-webkit-scrollbar-track { + background: #2a2a2a; +} + +#python-console::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 4px; +} + +#python-console::-webkit-scrollbar-thumb:hover { + background: #5a5a5a; +} + +/* Ensure buttons have proper disabled states */ +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/ui/src/input.css b/ui/src/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/ui/src/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 0000000..804b7f0 --- /dev/null +++ b/ui/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./*.html", + "./*.js", + "./src/**/*.{html,js}" + ], + theme: { + extend: {}, + }, + plugins: [], +} + |
