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