yum-slop/TaSTT
Free self-hosted STT for VRChat.
git clone https://git.yummers.dev/yum-slop/TaSTT
9a699c7
master
1const { app, BrowserWindow, ipcMain} = require ( 'electron' ); 2const path = require ( 'node:path' ); 3const fs = require ( 'node:fs' ). promises ; 4const yaml = require ( 'js-yaml' ); 5const { spawn} = require ( 'child_process' ); 6const https = require ( 'https' ); 7const { CONFIG_SCHEMA , getDefaultConfig} = require ( './config-schema.js' ); 8 9// Detect if we're running in development or production 10const isDev = ! app . isPackaged ; 11const APP_ROOT = isDev 12 ?path . join ( __dirname , '..' ) // Development: go up from ui/ to project root 13 :process . resourcesPath ; // Production: use Electron's resource path 14 15const CONFIG_PATH = path . join ( APP_ROOT , 'config.yaml' ); 16 17let mainWindow ; 18let runningProcess = null ; // Track the running Python process 19 20// Required DLL files for CUDA/cuDNN support 21const REQUIRED_DLLS = [ 22'cublas64_12.dll' , 23'cublasLt64_12.dll' , 24'cudnn64_9.dll' , 25'cudnn_adv64_9.dll' , 26'cudnn_cnn64_9.dll' , 27'cudnn_engines_precompiled64_9.dll' , 28'cudnn_engines_runtime_compiled64_9.dll' , 29'cudnn_graph64_9.dll' , 30'cudnn_heuristic64_9.dll' , 31'cudnn_ops64_9.dll' 32]; 33 34// Helper function to get the correct Python executable from embedded python 35function getVenvPython () { 36const pythonPath = path . join ( APP_ROOT , 'python' , 'python.exe' ); 37return pythonPath ; 38} 39 40// Helper function to send Python output to renderer 41function sendPythonOutput ( message , type = 'stdout' ) { 42if ( mainWindow && ! mainWindow . isDestroyed ()) { 43mainWindow . webContents . send ( 'python-output' , { message, type}); 44} 45} 46 47// Helper function to create environment with DLL path 48function createPythonEnvironment () { 49const dllPath = path . join ( APP_ROOT , 'dll' ); 50const binPath = path . join ( APP_ROOT , 'python' , 'Scripts' ); 51const env = {}; 52env . PATH = ` ${ dllPath } ; ${ binPath } ` ; 53env . HF_HUB_DISABLE_SYMLINKS_WARNING = '1' ; 54return env ; 55} 56 57// Helper function to download a file from URL with progress 58function downloadFile ( url , outputPath ) { 59return new Promise (( resolve , reject ) => { 60const file = require ( 'fs' ). createWriteStream ( outputPath ); 61const fileName = path . basename ( outputPath ); 62 63const request = https . get ( url , ( response ) => { 64if ( response . statusCode === 200 ) { 65const totalSize = parseInt ( response . headers [ 'content-length' ], 10 ); 66let downloadedSize = 0 ; 67let lastProgressTime = Date . now (); 68 69response . on ( 'data' , ( chunk ) => { 70downloadedSize += chunk . length ; 71 72// Log progress every 5 seconds 73const now = Date . now (); 74if ( totalSize && ( now - lastProgressTime >= 5000 )) { 75const progress = Math . round (( downloadedSize / totalSize ) * 100 ); 76const mb = ( downloadedSize / 1024 / 1024 ). toFixed ( 1 ); 77const totalMb = ( totalSize / 1024 / 1024 ). toFixed ( 1 ); 78sendPythonOutput ( `Downloading ${ fileName } : ${ mb } / ${ totalMb } MB ( ${ progress } %)` , 'info' ); 79lastProgressTime = now ; 80} 81}); 82 83response . pipe ( file ); 84 85file . on ( 'finish' , () => { 86file . close (); 87resolve (); 88}); 89 90file . on ( 'error' , ( err ) => { 91fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 92reject ( err ); 93}); 94} else { 95file . close (); 96fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 97reject ( new Error ( `Failed to download: HTTP ${ response . statusCode } ` )); 98} 99}); 100 101request . on ( 'error' , ( err ) => { 102file . close (); 103fs . unlink ( outputPath ). catch (() => {}); // Clean up on error 104reject ( err ); 105}); 106}); 107} 108 109function shouldFilterMessage ( message ) { 110// Filter out pydub ffmpeg/avconv warning. It does not actually matter. 111if ( message . includes ( "Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work" )) { 112return true ; 113} 114return false ; 115} 116 117// Helper function to setup process event handlers 118function setupProcessHandlers ( process ) { 119process . stdout . on ( 'data' , ( data ) => { 120const text = data . toString (); 121sendPythonOutput ( text . trimEnd (), 'stdout' ); 122}); 123 124process . stderr . on ( 'data' , ( data ) => { 125const text = data . toString (); 126if ( ! shouldFilterMessage ( text )) { 127sendPythonOutput ( text . trimEnd (), 'stderr' ); 128} 129}); 130 131process . on ( 'error' , ( error ) => { 132sendPythonOutput ( `Process error: ${ error . message } ` , 'stderr' ); 133runningProcess = null ; 134if ( mainWindow && ! mainWindow . isDestroyed ()) { 135mainWindow . webContents . send ( 'process-stopped' ); 136} 137}); 138 139process . on ( 'close' , ( code ) => { 140sendPythonOutput ( `Process exited with code ${ code } ` , 'info' ); 141runningProcess = null ; 142if ( mainWindow && ! mainWindow . isDestroyed ()) { 143mainWindow . webContents . send ( 'process-stopped' ); 144} 145}); 146} 147 148// Helper function to execute Python commands using embedded python 149function executePythonCommand ( args , options = {}) { 150return new Promise (( resolve , reject ) => { 151const pythonPath = getVenvPython (); 152const commandStr = ` ${ pythonPath } ${ args . join ( ' ' )} ` ; 153sendPythonOutput ( `> ${ commandStr } ` , 'info' ); 154 155const spawnOptions = { 156 ...options , 157env :createPythonEnvironment () 158}; 159 160const pythonProcess = spawn ( pythonPath , args , spawnOptions ); 161 162let stdout = '' ; 163let stderr = '' ; 164 165pythonProcess . stdout . on ( 'data' , ( data ) => { 166const text = data . toString (); 167stdout += text ; 168sendPythonOutput ( text . trimEnd (), 'stdout' ); 169}); 170 171pythonProcess . stderr . on ( 'data' , ( data ) => { 172const text = data . toString (); 173stderr += text ; 174// Filter out specific warning messages 175if ( ! shouldFilterMessage ( text )) { 176sendPythonOutput ( text . trimEnd (), 'stderr' ); 177} 178}); 179 180pythonProcess . on ( 'error' , ( error ) => { 181sendPythonOutput ( `Failed to start Python process: ${ error . message } ` , 'stderr' ); 182reject ({ error :error . message , stdout, stderr}); 183}); 184 185pythonProcess . on ( 'close' , ( code ) => { 186if ( code !== 0 ) { 187sendPythonOutput ( `Process exited with code ${ code } ` , 'stderr' ); 188reject ({ code, stdout, stderr}); 189} else { 190resolve ({ stdout, stderr}); 191} 192}); 193}); 194} 195 196function createWindow () { 197mainWindow = new BrowserWindow ({ 198width :1000 , 199height :800 , 200icon :path . join ( APP_ROOT , 'Images' , 'favicon.ico' ), 201webPreferences :{ 202preload :path . join ( __dirname , 'preload.js' ), 203contextIsolation :true , 204nodeIntegration :false 205} 206}); 207 208mainWindow . loadFile ( 'index.html' ); 209} 210 211// Replace the DEFAULT_CONFIG constant with: 212const DEFAULT_CONFIG = getDefaultConfig (); 213 214// IPC handlers 215ipcMain . handle ( 'load-config' , async () => { 216try { 217const fileContent = await fs . readFile ( CONFIG_PATH , 'utf8' ); 218return yaml . load ( fileContent ); 219} catch ( error ) { 220if ( error . code === 'ENOENT' ) { 221// Config file doesn't exist, create it with defaults 222console . error ( 'Config file not found, creating with defaults...' ); 223try { 224const yamlContent = yaml . dump ( DEFAULT_CONFIG , { lineWidth :- 1 }); 225await fs . writeFile ( CONFIG_PATH , yamlContent , 'utf8' ); 226console . error ( 'Created config.yaml with default values' ); 227return DEFAULT_CONFIG ; 228} catch ( writeError ) { 229console . error ( 'Error creating default config:' , writeError ); 230// Return defaults even if we can't write the file 231return DEFAULT_CONFIG ; 232} 233} 234console . error ( 'Error loading config:' , error ); 235throw error ; 236} 237}); 238 239ipcMain . handle ( 'save-config' , async ( event , config ) => { 240try { 241const yamlContent = yaml . dump ( config , { lineWidth :- 1 }); 242await fs . writeFile ( CONFIG_PATH , yamlContent , 'utf8' ); 243return { success :true }; 244} catch ( error ) { 245console . error ( 'Error saving config:' , error ); 246throw error ; 247} 248}); 249 250ipcMain . handle ( 'reset-config' , async () => { 251try { 252// Check if the file exists first 253try { 254await fs . access ( CONFIG_PATH ); 255// File exists, delete it 256await fs . unlink ( CONFIG_PATH ); 257console . error ( 'Config file deleted successfully' ); 258return { success :true , message :'Configuration reset to defaults' }; 259} catch ( error ) { 260if ( error . code === 'ENOENT' ) { 261// Config file doesn't exist, that's fine 262return { success :true , message :'Configuration already at defaults' }; 263} 264throw error ; 265} 266} catch ( error ) { 267console . error ( 'Error resetting config:' , error ); 268throw new Error ( `Failed to reset configuration: ${ error . message } ` ); 269} 270}); 271 272ipcMain . handle ( 'deleteVenvIndicatorFile' , async () => { 273const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 274try { 275await fs . unlink ( venvMarkerPath ); 276return { success :true , message :'.venv_is_set_up deleted successfully.' }; 277} catch ( error ) { 278if ( error . code === 'ENOENT' ) { 279return { success :true , message :'.venv_is_set_up not found.' }; 280} 281console . error ( 'Error deleting .venv_is_set_up file:' , error ); 282sendPythonOutput ( `Error deleting .venv_is_set_up: ${ error . message } ` , 'stderr' ); 283throw error ; 284} 285}); 286 287// Generic function to ensure required files are present 288async function ensureRequiredFiles ( config ) { 289const { 290 directoryName, 291 requiredFiles, 292 downloadBaseUrl, 293 resourceType 294} = config ; 295 296const targetPath = path . join ( APP_ROOT , directoryName ); 297 298try { 299// Check if target directory exists, create it if not 300try { 301await fs . access ( targetPath ); 302sendPythonOutput ( ` ${ resourceType } directory exists` , 'info' ); 303} catch ( error ) { 304if ( error . code === 'ENOENT' ) { 305sendPythonOutput ( `Creating ${ resourceType } directory...` , 'info' ); 306await fs . mkdir ( targetPath , { recursive :true }); 307sendPythonOutput ( ` ${ resourceType } directory created` , 'info' ); 308} else { 309throw error ; 310} 311} 312 313// Check each required file 314const missingFiles = []; 315for ( const fileName of requiredFiles ) { 316const filePath = path . join ( targetPath , fileName ); 317try { 318await fs . access ( filePath ); 319sendPythonOutput ( `✓ ${ fileName } exists` , 'info' ); 320} catch ( error ) { 321if ( error . code === 'ENOENT' ) { 322missingFiles . push ( fileName ); 323sendPythonOutput ( `✗ ${ fileName } missing` , 'info' ); 324} else { 325throw error ; 326} 327} 328} 329 330// Download missing files 331if ( missingFiles . length > 0 ) { 332sendPythonOutput ( `Downloading ${ missingFiles . length } missing ${ resourceType } file ${ missingFiles . length > 1 ? 's' : '' } ...` , 'info' ); 333 334for ( const fileName of missingFiles ) { 335const filePath = path . join ( targetPath , fileName ); 336const downloadUrl = ` ${ downloadBaseUrl } / ${ fileName } ` ; 337 338try { 339sendPythonOutput ( `Downloading ${ fileName } ...` , 'info' ); 340await downloadFile ( downloadUrl , filePath ); 341sendPythonOutput ( `✓ Downloaded ${ fileName } ` , 'info' ); 342} catch ( downloadError ) { 343sendPythonOutput ( `✗ Failed to download ${ fileName } : ${ downloadError . message } ` , 'stderr' ); 344throw new Error ( `Failed to download ${ fileName } : ${ downloadError . message } ` ); 345} 346} 347 348sendPythonOutput ( `All missing ${ resourceType } files downloaded successfully` , 'info' ); 349} else { 350sendPythonOutput ( `All required ${ resourceType } files are present` , 'info' ); 351} 352 353return { 354success :true , 355message :` ${ resourceType } setup complete. ${ missingFiles . length } file ${ missingFiles . length > 1 ? 's' : '' } downloaded.` , 356downloadedFiles :missingFiles 357}; 358} catch ( error ) { 359console . error ( `Error setting up ${ resourceType } files:` , error ); 360throw new Error ( ` ${ resourceType } setup failed: ${ error . message } ` ); 361} 362} 363 364// Update the install-requirements handler 365ipcMain . handle ( 'install-requirements' , async () => { 366const requirementsPath = path . join ( APP_ROOT , 'app' , 'requirements.txt' ); 367const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 368 369try { 370// Check if venv is already set up 371try { 372await fs . access ( venvMarkerPath ); 373return { success :true , message :'Virtual environment already set up' }; 374} catch ( error ) { 375// Marker doesn't exist, proceed with setup 376} 377 378// Check if requirements.txt exists 379await fs . access ( requirementsPath ); 380 381await executePythonCommand ([ '-m' , 'pip' , 'install' , '-r' , requirementsPath ]); 382 383await ensureRequiredFiles ({ 384directoryName :'dll' , 385requiredFiles :REQUIRED_DLLS , 386downloadBaseUrl :'https://yummers.dev/tastt/dll' , 387resourceType :'DLL' 388}); 389 390await fs . mkdir ( path . join ( APP_ROOT , 'Models' ), { recursive :true }); 391 392await fs . writeFile ( venvMarkerPath , new Date (). toISOString (), 'utf8' ); 393sendPythonOutput ( 'Created .venv_is_set_up marker file' , 'info' ); 394 395return { success :true , message :'Requirements and dependencies installed successfully' }; 396} catch ( error ) { 397console . error ( 'Error installing requirements:' , error ); 398if ( error . code === 'ENOENT' ) { 399throw new Error ( 'requirements.txt not found' ); 400} 401 402const errorDetails = error . stderr || error . stdout || error . message || error . error || 'Unknown error' ; 403throw new Error ( `Installation failed: ${ errorDetails } ` ); 404} 405}); 406 407ipcMain . handle ( 'get-microphones' , async () => { 408const scriptPath = path . join ( APP_ROOT , 'app' , 'list_microphones.py' ); 409 410try { 411const result = await executePythonCommand ([ scriptPath ]); 412const microphones = JSON . parse ( result . stdout . trim ()); 413return microphones ; 414} catch ( error ) { 415console . error ( 'Failed to get microphones:' , error ); 416throw new Error ( `Failed to get microphones: ${ error . stderr || error . error || 'Unknown error' } ` ); 417} 418}); 419 420// Helper function to safely delete directory contents 421async function clearDirectory ( dirPath , dirName ) { 422try { 423await fs . access ( dirPath ); 424sendPythonOutput ( `Clearing ${ dirName } directory...` , 'info' ); 425 426const files = await fs . readdir ( dirPath ); 427let deletedCount = 0 ; 428 429for ( const file of files ) { 430const filePath = path . join ( dirPath , file ); 431 432try { 433await fs . rm ( filePath , { recursive :true , force :true }); 434sendPythonOutput ( `✗ Deleted file ${ file } ` , 'info' ); 435 436deletedCount ++ ; 437} catch ( deleteError ) { 438sendPythonOutput ( `Warning: Could not delete ${ file } : ${ deleteError . message } ` , 'stderr' ); 439// Continue with other files even if one fails 440} 441} 442 443sendPythonOutput ( ` ${ dirName } directory cleared` , 'info' ); 444return deletedCount ; 445} catch ( error ) { 446if ( error . code === 'ENOENT' ) { 447sendPythonOutput ( ` ${ dirName } directory doesn't exist, skipping` , 'info' ); 448return 0 ; 449} else { 450sendPythonOutput ( `Error clearing ${ dirName } directory: ${ error . message } ` , 'stderr' ); 451throw error ; 452} 453} 454} 455 456ipcMain . handle ( 'reset-venv' , async () => { 457const venvMarkerPath = path . join ( APP_ROOT , '.venv_is_set_up' ); 458 459try { 460sendPythonOutput ( 'Starting virtual environment reset...' , 'info' ); 461 462// Delete the venv marker file first 463try { 464await fs . unlink ( venvMarkerPath ); 465sendPythonOutput ( 'Deleted .venv_is_set_up marker file' , 'info' ); 466} catch ( error ) { 467if ( error . code !== 'ENOENT' ) { 468sendPythonOutput ( `Warning: Could not delete marker file: ${ error . message } ` , 'stderr' ); 469} 470} 471 472// Get list of installed packages 473sendPythonOutput ( 'Getting list of installed packages...' , 'info' ); 474const freezeResult = await executePythonCommand ([ '-m' , 'pip' , 'freeze' ]); 475const installedPackages = freezeResult . stdout . trim (); 476 477let uninstalledPackages = []; 478 479if ( ! installedPackages ) { 480sendPythonOutput ( 'No packages found to uninstall' , 'info' ); 481} else { 482// Parse package names and filter out core packages 483const packageLines = installedPackages . split ( '\n' ). filter ( line => line . trim ()); 484const packageNames = packageLines 485. map ( line => line . split ( '==' )[ 0 ]. trim ()) 486. filter ( name => name && ! name . startsWith ( '#' )); 487 488const corePackages = [ 'pip' , 'setuptools' , 'wheel' ]; 489const packagesToUninstall = packageNames . filter ( name => ! corePackages . includes ( name . toLowerCase ())); 490 491if ( packagesToUninstall . length === 0 ) { 492sendPythonOutput ( 'Only core packages found, nothing to uninstall' , 'info' ); 493} else { 494sendPythonOutput ( `Uninstalling ${ packagesToUninstall . length } packages...` , 'info' ); 495 496const uninstallArgs = [ '-m' , 'pip' , 'uninstall' , '-y' , ...packagesToUninstall ]; 497await executePythonCommand ( uninstallArgs ); 498uninstalledPackages = packagesToUninstall ; 499} 500} 501 502// Clear downloaded files 503sendPythonOutput ( 'Clearing downloaded files...' , 'info' ); 504 505const dllPath = path . join ( APP_ROOT , 'dll' ); 506const modelsPath = path . join ( APP_ROOT , 'Models' ); 507const binPath = path . join ( APP_ROOT , 'bin' ); 508 509const deletedDlls = await clearDirectory ( dllPath , 'DLL' ); 510const deletedModels = await clearDirectory ( modelsPath , 'Models' ); 511const deletedBins = await clearDirectory ( binPath , 'Binary' ); 512 513const totalDeletedFiles = deletedDlls + deletedModels + deletedBins ; 514 515sendPythonOutput ( 'Virtual environment reset successfully!' , 'info' ); 516 517return { 518success :true , 519message :`Virtual environment reset complete. Uninstalled ${ uninstalledPackages . length } packages and deleted ${ totalDeletedFiles } downloaded files.` , 520 uninstalledPackages, 521deletedFiles :{ 522dlls :deletedDlls , 523models :deletedModels , 524binaries :deletedBins , 525total :totalDeletedFiles 526} 527}; 528} catch ( error ) { 529console . error ( 'Error resetting virtual environment:' , error ); 530throw new Error ( `Virtual environment reset failed: ${ error . message } ` ); 531} 532}); 533 534// Add handlers for starting and stopping the process 535ipcMain . handle ( 'start-process' , async () => { 536if ( runningProcess ) { 537throw new Error ( 'Process is already running' ); 538} 539 540const scriptPath = path . join ( APP_ROOT , 'app' , 'hi.py' ); 541const args = [ scriptPath , '--config' , CONFIG_PATH ]; 542 543try { 544const pythonPath = getVenvPython (); 545sendPythonOutput ( `Starting process: ${ path . basename ( pythonPath )} ${ args . join ( ' ' )} ` , 'info' ); 546 547runningProcess = spawn ( pythonPath , args , { env :createPythonEnvironment () }); 548setupProcessHandlers ( runningProcess ); 549 550return { success :true }; 551} catch ( error ) { 552runningProcess = null ; 553throw error ; 554} 555}); 556 557ipcMain . handle ( 'stop-process' , async () => { 558if ( ! runningProcess ) { 559sendPythonOutput ( 'No process to stop' , 'info' ); 560return { success :true , forcefullyKilled :false }; 561} 562 563return new Promise (( resolve ) => { 564let forcefullyKilled = false ; 565 566// Set up a timeout to force kill after 10 seconds 567const killTimeout = setTimeout (() => { 568if ( runningProcess ) { 569sendPythonOutput ( 'Process did not stop gracefully, forcing termination...' , 'stderr' ); 570forcefullyKilled = true ; 571runningProcess . kill ( 'SIGKILL' ); 572} 573}, 10000 ); 574 575// Listen for the process to exit 576runningProcess . once ( 'exit' , ( code , signal ) => { 577clearTimeout ( killTimeout ); 578runningProcess = null ; 579 580if ( forcefullyKilled ) { 581sendPythonOutput ( 'Process forcefully terminated' , 'info' ); 582} else { 583sendPythonOutput ( 'Process stopped gracefully' , 'info' ); 584} 585 586resolve ({ success :true , forcefullyKilled}); 587}); 588 589// Send termination signal 590sendPythonOutput ( 'Stopping process gracefully...' , 'info' ); 591runningProcess . kill ( 'SIGTERM' ); 592}); 593}); 594 595ipcMain . handle ( 'get-process-state' , () => { 596return { isRunning :runningProcess !== null }; 597}); 598 599// Clean up on app quit 600app . on ( 'before-quit' , () => { 601if ( runningProcess ) { 602runningProcess . kill (); 603} 604}); 605 606app . whenReady (). then (() => { 607createWindow (); 608 609app . on ( 'activate' , function () { 610if ( BrowserWindow . getAllWindows (). length === 0 ) createWindow (); 611}); 612}); 613 614app . on ( 'window-all-closed' , function () { 615app . quit (); 616}); 617