yum-slop/TaSTT

Free self-hosted STT for VRChat.

git clone https://git.yummers.dev/yum-slop/TaSTT

yumDrop turbo; use old logic when no_speech ts availablebce0853

master
19.2 KiB536 linesraw
1// Import configuration schema
2const CONFIG_FIELDS = window.CONFIG_SCHEMA;
3
4// Process state tracking
5let isProcessRunning = false;
6let buttonManager;
7let loadingOverlay;
8
9// Auto-save functionality with debouncing
10let saveTimeout;
11const SAVE_DELAY = 500;
12let isSettingValues = false;
13
14// Console management
15const consoleContent = document.getElementById('console-content');
16const MAX_CONSOLE_LINES = 512;
17let consoleLineCount = 0;
18
19// Button management system
20class ButtonManager {
21    constructor() {
22        this.buttons = {
23            start: document.getElementById('start-process'),
24            stop: document.getElementById('stop-process'),
25            setupVenv: document.getElementById('setup-venv'),
26            resetVenv: document.getElementById('reset-venv'),
27            refreshMicrophones: document.getElementById('refresh-microphones')
28        };
29
30        // Initialize button states - process is not running at startup
31        this.setProcessStopped();
32    }
33
34    setState(buttonName, disabled) {
35        const button = this.buttons[buttonName];
36        if (!button) return;
37
38        button.disabled = disabled;
39    }
40
41    setProcessRunning() {
42        this.setState('start', true);
43        this.setState('stop', false);
44        isProcessRunning = true;
45    }
46
47    setProcessStopped() {
48        this.setState('start', false);
49        this.setState('stop', true);
50        isProcessRunning = false;
51    }
52
53    async withButtonLoading(buttonName, asyncFn) {
54        this.setState(buttonName, true);
55        try {
56            return await asyncFn();
57        } finally {
58            this.setState(buttonName, false);
59        }
60    }
61}
62
63// Add loading overlay management
64class LoadingOverlay {
65    constructor() {
66        this.overlay = document.getElementById('loading-overlay');
67        this.form = document.getElementById('config-form');
68        this.messageElement = this.overlay.querySelector('p');
69        this.defaultMessage = 'Environment setup underway - please wait.';
70        this.originalStates = new Map(); // Track original disabled states
71    }
72
73    show(message = null) {
74        this.messageElement.textContent = message || this.defaultMessage;
75        this.overlay.classList.remove('hidden');
76        // Disable all form inputs and buttons in the entire left panel
77        const leftPanel = this.overlay.parentElement;
78        const inputs = leftPanel.querySelectorAll('input, select, textarea, button');
79        inputs.forEach(input => {
80            // Store original disabled state before disabling
81            this.originalStates.set(input, input.disabled);
82            input.disabled = true;
83            input.classList.add('opacity-50');
84        });
85    }
86
87    hide() {
88        this.overlay.classList.add('hidden');
89        // Restore original states of form inputs and buttons
90        const leftPanel = this.overlay.parentElement;
91        const inputs = leftPanel.querySelectorAll(
92          'input, select, textarea, button');
93        inputs.forEach(input => {
94            // Restore original disabled state
95            input.disabled = this.originalStates.get(input) || false;
96            input.classList.remove('opacity-50');
97        });
98        // Clear the stored states
99        this.originalStates.clear();
100        // Reset to default message
101        this.messageElement.textContent = this.defaultMessage;
102    }
103}
104
105// Handle status messages with better color management
106function showStatus(message, type = 'info') {
107    const statusEl = document.getElementById('status-message');
108    statusEl.textContent = message;
109
110    // Remove all status classes
111    const statusClasses = ['hidden', 'bg-green-100', 'bg-red-100',
112      'bg-blue-100', 'text-green-800', 'text-red-800', 'text-blue-800'];
113    statusEl.classList.remove(...statusClasses);
114
115    // Add appropriate classes based on type
116    const typeMap = {
117        success: ['bg-green-100', 'text-green-800'],
118        error: ['bg-red-100', 'text-red-800'],
119        info: ['bg-blue-100', 'text-blue-800']
120    };
121
122    statusEl.classList.add(...(typeMap[type] || typeMap.info));
123
124    // Also log to console
125    appendToConsole(message, type === 'error' ? 'stderr' : 'info');
126
127    setTimeout(() => statusEl.classList.add('hidden'), 5000);
128}
129
130// Get form values using field mappings
131function getFormValues() {
132    const config = {};
133
134    for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
135        const element = document.getElementById(fieldName);
136        if (!element) continue;
137
138        switch (fieldConfig.type) {
139            case 'boolean':
140                config[fieldName] = element.checked ? 1 : 0;
141                break;
142            case 'number':
143                const numValue = parseInt(element.value);
144                config[fieldName] = isNaN(numValue) ? fieldConfig.default : numValue;
145                break;
146            case 'text':
147                config[fieldName] = element.value || fieldConfig.default;
148                break;
149            default:
150                config[fieldName] = element.value || fieldConfig.default;
151        }
152    }
153
154    return config;
155}
156
157// Set form values using field mappings
158function setFormValues(config) {
159    isSettingValues = true; // Disable auto-save temporarily
160
161    for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
162        const element = document.getElementById(fieldName);
163        if (!element) continue;
164
165        const value = config[fieldName] ?? fieldConfig.default;
166
167        switch (fieldConfig.type) {
168            case 'boolean':
169                element.checked = value === 1;
170                break;
171            case 'text':
172                element.value = value || '';
173                break;
174            default:
175                element.value = value;
176        }
177    }
178
179    // Handle use_builtin toggle state
180    const useBuiltin = config.use_builtin === 1;
181    const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
182    customChatboxInputs.forEach(inputId => {
183        const input = document.getElementById(inputId);
184        if (input) {
185            input.disabled = useBuiltin;
186            if (useBuiltin) {
187                input.classList.add('opacity-50', 'cursor-not-allowed');
188            } else {
189                input.classList.remove('opacity-50', 'cursor-not-allowed');
190            }
191        }
192    });
193
194    // Update volume display
195    if (config.volume !== undefined) {
196        const volumePercent = Math.round(config.volume);
197        document.getElementById('volume-display').textContent = `${volumePercent}%`;
198    }
199
200    isSettingValues = false; // Re-enable auto-save
201}
202
203function appendToConsole(message, type = 'stdout') {
204    const timestamp = new Date().toLocaleTimeString();
205    const timestampSpan = document.createElement('span');
206    timestampSpan.className = 'console-timestamp';
207    timestampSpan.textContent = `[${timestamp}] `;
208
209    const messageSpan = document.createElement('span');
210    messageSpan.className = `console-${type}`;
211
212    // Redact usernames from paths.
213    const redactedMessage = message.replace(/([A-Za-z]:[\/\\]Users[\/\\])([^\/\\]+)/g, '$1*****');
214    messageSpan.textContent = redactedMessage;
215
216    const lineDiv = document.createElement('div');
217    lineDiv.appendChild(timestampSpan);
218    lineDiv.appendChild(messageSpan);
219
220    consoleContent.appendChild(lineDiv);
221    consoleLineCount++;
222
223    // Remove old lines if we exceed the limit
224    if (consoleLineCount > MAX_CONSOLE_LINES) {
225        // Calculate how many lines to remove (remove 10% to avoid frequent trimming)
226        const linesToRemove = Math.floor(MAX_CONSOLE_LINES * 0.1);
227
228        // Remove the oldest lines
229        for (let i = 0; i < linesToRemove; i++) {
230            if (consoleContent.firstChild) {
231                consoleContent.removeChild(consoleContent.firstChild);
232            }
233        }
234
235        consoleLineCount -= linesToRemove;
236
237        // Add a notice that lines were trimmed
238        const trimNotice = document.createElement('div');
239        trimNotice.className = 'console-info';
240        trimNotice.innerHTML = '<span class="console-timestamp">[System] </span><span class="console-info">... older lines removed to maintain performance ...</span>';
241        consoleContent.insertBefore(trimNotice, consoleContent.firstChild);
242    }
243
244    // Auto-scroll to bottom
245    const pythonConsole = document.getElementById('python-console');
246    pythonConsole.scrollTop = pythonConsole.scrollHeight;
247}
248
249// Async action handler with better error handling
250async function handleAsyncAction(actionName, actionFn) {
251    try {
252        const result = await actionFn();
253        if (result?.message) {
254            showStatus(result.message, 'success');
255        }
256        return result;
257    } catch (error) {
258        showStatus(`${actionName} failed: ${error.message}`, 'error');
259        throw error;
260    }
261}
262
263async function autoSaveConfig() {
264    if (isSettingValues) return;
265
266    clearTimeout(saveTimeout);
267    saveTimeout = setTimeout(async () => {
268        try {
269            const config = getFormValues();
270            await window.electronAPI.saveConfig(config);
271            showStatus('Configuration saved', 'success');
272
273            // Restart process if running
274            if (isProcessRunning) {
275                appendToConsole('Restarting process with new configuration...', 'info');
276
277                try {
278                    await window.electronAPI.stopProcess();
279                    await new Promise(resolve => setTimeout(resolve, 1000));
280                    await window.electronAPI.startProcess();
281                    buttonManager.setProcessRunning();
282                    appendToConsole('Process restarted with new configuration', 'info');
283                } catch (error) {
284                    appendToConsole(`Failed to restart process: ${error.message}`, 'stderr');
285                    buttonManager.setProcessStopped();
286                }
287            }
288        } catch (error) {
289            showStatus(`Failed to save configuration: ${error.message}`, 'error');
290        }
291    }, SAVE_DELAY);
292}
293
294// Auto-save setup
295function setupAutoSave() {
296    const form = document.getElementById('config-form');
297    const inputs = form.querySelectorAll('input, select, textarea');
298
299    inputs.forEach(input => {
300        const eventType = input.type === 'checkbox' ? 'change' :
301                         (input.type === 'number' || input.type === 'text' || input.tagName === 'TEXTAREA') ? 'input' : 'change';
302        input.addEventListener(eventType, autoSaveConfig);
303    });
304}
305
306// Microphone loading
307async function loadMicrophones() {
308    const microphoneSelect = document.getElementById('microphone');
309
310    try {
311        // Check/install requirements during startup
312        appendToConsole('Checking virtual environment and requirements...', 'info');
313        loadingOverlay.show('Setting up environment - this can take several minutes.');
314        try {
315            await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
316        } finally {
317            loadingOverlay.hide(); // Always hide overlay when done
318        }
319
320        appendToConsole('Loading available microphones...', 'info');
321        const microphones = await window.electronAPI.getMicrophones();
322
323        microphoneSelect.innerHTML = '';
324
325        if (microphones.length === 0) {
326            microphoneSelect.innerHTML = '<option value="" disabled>No microphones found</option>';
327            appendToConsole('No microphones found', 'stderr');
328            return;
329        }
330
331        appendToConsole(`Found ${microphones.length} microphone(s)`, 'info');
332        microphones.forEach(mic => {
333            const option = document.createElement('option');
334            option.value = mic.index.toString();
335            option.textContent = mic.name;
336            microphoneSelect.appendChild(option);
337            appendToConsole(`  - ${mic.name} (Device ${mic.index})`, 'stdout');
338        });
339
340        // Restore previously selected microphone
341        try {
342            const config = await window.electronAPI.loadConfig();
343            if (config.microphone) {
344                microphoneSelect.value = config.microphone;
345            }
346        } catch (error) {
347            // Ignore config load errors here
348        }
349
350    } catch (error) {
351        appendToConsole(`Failed to load microphones: ${error.message}`, 'stderr');
352        microphoneSelect.innerHTML = '<option value="" disabled>Error loading microphones</option>';
353    }
354}
355
356// Event handlers setup
357function setupEventHandlers() {
358    // Advanced settings toggle
359    document.getElementById('toggle-advanced').addEventListener('click', () => {
360        const advancedSettings = document.getElementById('advanced-settings');
361        const chevron = document.getElementById('chevron');
362
363        if (advancedSettings.classList.contains('hidden')) {
364            advancedSettings.classList.remove('hidden');
365            chevron.classList.add('rotate-90');
366        } else {
367            advancedSettings.classList.add('hidden');
368            chevron.classList.remove('rotate-90');
369        }
370    });
371
372    // Use builtin chatbox toggle
373    document.getElementById('use_builtin').addEventListener('change', (e) => {
374        const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
375        const isBuiltin = e.target.checked;
376
377        customChatboxInputs.forEach(inputId => {
378            const input = document.getElementById(inputId);
379            if (input) {
380                input.disabled = isBuiltin;
381                if (isBuiltin) {
382                    input.classList.add('opacity-50', 'cursor-not-allowed');
383                } else {
384                    input.classList.remove('opacity-50', 'cursor-not-allowed');
385                }
386            }
387        });
388    });
389
390    // Volume slider update
391    document.getElementById('volume').addEventListener('input', (e) => {
392        const volumePercent = Math.round(e.target.value);
393        document.getElementById('volume-display').textContent = `${volumePercent}%`;
394    });
395
396    // Setup virtual environment
397    document.getElementById('setup-venv').addEventListener('click', async () => {
398        loadingOverlay.show('Setting up virtual environment - please wait...'); // Show overlay with custom message
399        try {
400            await buttonManager.withButtonLoading('setupVenv', async () => {
401                await window.electronAPI.deleteVenvIndicatorFile();
402                await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
403            });
404        } finally {
405            loadingOverlay.hide(); // Always hide overlay when done
406        }
407    });
408
409    // Reset virtual environment
410    document.getElementById('reset-venv').addEventListener('click', async () => {
411        loadingOverlay.show('Resetting virtual environment - please wait...'); // Show overlay with custom message
412        try {
413            await buttonManager.withButtonLoading('resetVenv', async () => {
414                await handleAsyncAction('Reset virtual environment', () => window.electronAPI.resetVenv());
415            });
416        } finally {
417            loadingOverlay.hide(); // Always hide overlay when done
418        }
419    });
420
421    // Reset configuration
422    document.getElementById('reset-config').addEventListener('click', async () => {
423        const confirmReset = confirm('Are you sure you want to reset all settings to defaults? This cannot be undone.');
424        if (!confirmReset) return;
425
426        try {
427            // Stop process if running
428            const wasRunning = isProcessRunning;
429            if (wasRunning) {
430                appendToConsole('Stopping process before resetting configuration...', 'info');
431                await window.electronAPI.stopProcess();
432                buttonManager.setProcessStopped();
433                await new Promise(resolve => setTimeout(resolve, 500));
434            }
435
436            // Reset configuration
437            appendToConsole('Resetting configuration to defaults...', 'info');
438            const result = await window.electronAPI.resetConfig();
439
440            // Reload configuration with defaults
441            const config = await window.electronAPI.loadConfig();
442            setFormValues(config);
443
444            showStatus(result.message, 'success');
445            appendToConsole('Configuration reset successfully', 'info');
446
447            // Restart process if it was running
448            if (wasRunning) {
449                appendToConsole('Restarting process with default configuration...', 'info');
450                await window.electronAPI.startProcess();
451                buttonManager.setProcessRunning();
452                appendToConsole('Process restarted with default configuration', 'info');
453            }
454        } catch (error) {
455            showStatus(`Failed to reset configuration: ${error.message}`, 'error');
456            appendToConsole(`Failed to reset configuration: ${error.message}`, 'stderr');
457        }
458    });
459
460    // Refresh microphones
461    document.getElementById('refresh-microphones').addEventListener('click', async () => {
462        await buttonManager.withButtonLoading('refreshMicrophones', async () => {
463            await loadMicrophones();
464        });
465    });
466
467    // Start process
468    document.getElementById('start-process').addEventListener('click', async () => {
469        buttonManager.setState('start', true);
470
471        try {
472            // The installRequirements function will now check if venv is set up.
473            loadingOverlay.show('Verifying environment setup - please wait...'); // Show overlay with custom message
474            try {
475                await window.electronAPI.installRequirements();
476                appendToConsole('Virtual environment setup checked/completed', 'info');
477            } finally {
478                loadingOverlay.hide(); // Always hide overlay when done
479            }
480
481            await window.electronAPI.startProcess();
482            buttonManager.setProcessRunning();
483            appendToConsole('Process started successfully', 'info');
484        } catch (error) {
485            appendToConsole(`Failed to start process: ${error.message}`, 'stderr');
486            buttonManager.setState('start', false);
487        }
488    });
489
490    // Stop process
491    document.getElementById('stop-process').addEventListener('click', async () => {
492        buttonManager.setState('stop', true);
493
494        try {
495            await window.electronAPI.stopProcess();
496            appendToConsole('Process stop initiated', 'info');
497        } catch (error) {
498            appendToConsole(`Failed to stop process: ${error.message}`, 'stderr');
499            buttonManager.setState('stop', false);
500        }
501    });
502
503    // Listen for process stopped event
504    window.electronAPI.onProcessStopped(() => {
505        buttonManager.setProcessStopped();
506    });
507}
508
509// Initialize application
510window.addEventListener('load', async () => {
511    appendToConsole('TaSTT Configuration UI initialized', 'info');
512
513    loadingOverlay = new LoadingOverlay();
514    buttonManager = new ButtonManager();
515
516    // Set up Python output listener first so we capture all output
517    window.electronAPI.onPythonOutput((data) => {
518        appendToConsole(data.message, data.type);
519    });
520
521    // Load configuration
522    try {
523        const config = await window.electronAPI.loadConfig();
524        setFormValues(config);
525        appendToConsole('Configuration loaded', 'info');
526    } catch (error) {
527        appendToConsole(`Failed to load configuration: ${error.message}`, 'stderr');
528    }
529
530    // Load microphones
531    await loadMicrophones();
532
533    // Setup event handlers and auto-save
534    setupEventHandlers();
535    setupAutoSave();
536});