summaryrefslogtreecommitdiffstats
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/.gitignore5
-rw-r--r--ui/build_scripts/setup-empty-venv.js25
-rw-r--r--ui/config-schema.js57
-rw-r--r--ui/index.html349
-rw-r--r--ui/index.js616
-rw-r--r--ui/package.json118
-rw-r--r--ui/postcss.config.js6
-rw-r--r--ui/preload.js17
-rw-r--r--ui/renderer.js531
-rw-r--r--ui/src/components.css122
-rw-r--r--ui/src/input.css3
-rw-r--r--ui/tailwind.config.js13
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: [],
+}
+