yum-slop/TaSTT

Free self-hosted STT for VRChat.

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

yumSwitch to embedded python9a699c7

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