import { exec, spawn } from 'child_process' import { app } from 'electron' import { is } from '@electron-toolkit/utils' import * as path from 'path' import * as net from 'net' import { controlWindow } from '../ControlWindow' import { allConfig } from './AllConfig' import { i18n } from '../i18n' import { Log } from './Log' import { passwordMaskingForList } from './UtilsFunc' export class CaptionEngine { appPath: string = '' command: string[] = [] process: any | undefined client: net.Socket | undefined port: number = 8080 status: 'running' | 'starting' | 'stopping' | 'stopped' | 'starting-timeout' = 'stopped' timerID: NodeJS.Timeout | undefined startTimeoutID: NodeJS.Timeout | undefined private getApp(): boolean { if (allConfig.controls.customized) { Log.info('Using customized caption engine') this.appPath = allConfig.controls.customizedApp this.command = allConfig.controls.customizedCommand.split(' ') this.port = Math.floor(Math.random() * (65535 - 1024 + 1)) + 1024 this.command.push('-p', this.port.toString()) } else { if(allConfig.controls.engine === 'gummy' && !allConfig.controls.API_KEY && !process.env.DASHSCOPE_API_KEY ) { controlWindow.sendErrorMessage(i18n('gummy.key.missing')) return false } this.command = [] if (is.dev) { if(process.platform === "win32") { this.appPath = path.join( app.getAppPath(), 'engine', '.venv', 'Scripts', 'python.exe' ) this.command.push(path.join( app.getAppPath(), 'engine', 'main.py' )) // this.appPath = path.join(app.getAppPath(), 'engine', 'dist', 'main.exe') } else { this.appPath = path.join( app.getAppPath(), 'engine', '.venv', 'bin', 'python3' ) this.command.push(path.join( app.getAppPath(), 'engine', 'main.py' )) } } else { if(process.platform === 'win32') { this.appPath = path.join(process.resourcesPath, 'engine', 'main.exe') } else { this.appPath = path.join(process.resourcesPath, 'engine', 'main', 'main') } } this.command.push('-a', allConfig.controls.audio ? '1' : '0') if(allConfig.controls.recording) { this.command.push('-r', '1') this.command.push('-rp', `"${allConfig.controls.recordingPath}"`) } this.port = Math.floor(Math.random() * (65535 - 1024 + 1)) + 1024 this.command.push('-p', this.port.toString()) this.command.push( '-t', allConfig.controls.translation ? allConfig.controls.targetLang : 'none' ) if(allConfig.controls.engine === 'gummy') { this.command.push('-e', 'gummy') this.command.push('-s', allConfig.controls.sourceLang) if(allConfig.controls.API_KEY) { this.command.push('-k', allConfig.controls.API_KEY) } } else if(allConfig.controls.engine === 'vosk'){ this.command.push('-e', 'vosk') this.command.push('-vosk', `"${allConfig.controls.voskModelPath}"`) this.command.push('-tm', allConfig.controls.transModel) this.command.push('-omn', allConfig.controls.ollamaName) if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) } else if(allConfig.controls.engine === 'sosv'){ this.command.push('-e', 'sosv') this.command.push('-s', allConfig.controls.sourceLang) this.command.push('-sosv', `"${allConfig.controls.sosvModelPath}"`) this.command.push('-tm', allConfig.controls.transModel) this.command.push('-omn', allConfig.controls.ollamaName) if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) } else if(allConfig.controls.engine === 'glm'){ this.command.push('-e', 'glm') this.command.push('-s', allConfig.controls.sourceLang) this.command.push('-gurl', allConfig.controls.glmUrl) this.command.push('-gmodel', allConfig.controls.glmModel) if(allConfig.controls.glmApiKey) { this.command.push('-gkey', allConfig.controls.glmApiKey) } this.command.push('-tm', allConfig.controls.transModel) this.command.push('-omn', allConfig.controls.ollamaName) if(allConfig.controls.ollamaUrl) this.command.push('-ourl', allConfig.controls.ollamaUrl) if(allConfig.controls.ollamaApiKey) this.command.push('-okey', allConfig.controls.ollamaApiKey) } } Log.info('Engine Path:', this.appPath) Log.info('Engine Command:', passwordMaskingForList(this.command)) return true } public connect() { if(this.client) { Log.warn('Client already exists, ignoring...') } if (this.startTimeoutID) { clearTimeout(this.startTimeoutID) this.startTimeoutID = undefined } this.client = net.createConnection({ port: this.port }, () => { Log.info('Connected to caption engine server'); }); this.status = 'running' allConfig.controls.engineEnabled = true if(controlWindow.window){ allConfig.sendControls(controlWindow.window, false) controlWindow.window.webContents.send( 'control.engine.started', this.process.pid ) } } public sendCommand(command: string, content: string = "") { if(this.client === undefined) { Log.error('Client not initialized yet') return } const data = JSON.stringify({command, content}) this.client.write(data); Log.info(`Send data to python server: ${data}`); } public start() { if (this.status !== 'stopped') { Log.warn('Caption engine is not stopped, current status:', this.status) return } if(!this.getApp()){ return } this.process = spawn(this.appPath, this.command) this.status = 'starting' Log.info('Caption Engine Starting, PID:', this.process.pid) const timeoutMs = allConfig.controls.startTimeoutSeconds * 1000 this.startTimeoutID = setTimeout(() => { if (this.status === 'starting') { Log.warn(`Engine start timeout after ${allConfig.controls.startTimeoutSeconds} seconds, forcing kill...`) this.status = 'starting-timeout' controlWindow.sendErrorMessage(i18n('engine.start.timeout')) this.kill() } }, timeoutMs) this.process.stdout.on('data', (data: any) => { const lines = data.toString().split('\n') lines.forEach((line: string) => { if (line.trim()) { try { const data_obj = JSON.parse(line) handleEngineData(data_obj) } catch (e) { // controlWindow.sendErrorMessage(i18n('engine.output.parse.error') + e) Log.error('Error parsing JSON:', e) } } }); }); this.process.stderr.on('data', (data: any) => { const lines = data.toString().split('\n') lines.forEach((line: string) => { if(line.trim()){ Log.error(line) } }) }); this.process.on('close', (code: any) => { this.process = undefined; this.client = undefined allConfig.controls.engineEnabled = false if(controlWindow.window){ allConfig.sendControls(controlWindow.window, false) controlWindow.window.webContents.send('control.engine.stopped') } this.status = 'stopped' clearInterval(this.timerID) if (this.startTimeoutID) { clearTimeout(this.startTimeoutID) this.startTimeoutID = undefined } Log.info(`Engine exited with code ${code}`) }); } public stop() { if(this.status !== 'running'){ Log.warn('Trying to stop engine which is not running, current status:', this.status) } this.sendCommand('stop') if(this.client){ this.client.destroy() this.client = undefined } this.status = 'stopping' this.timerID = setTimeout(() => { if(this.status !== 'stopping') return Log.warn('Engine process still not stopped, trying to kill...') this.kill() }, 4000); } public kill(){ if(!this.process || !this.process.pid) return if(this.status !== 'running'){ Log.warn('Trying to kill engine which is not running, current status:', this.status) } Log.warn('Killing engine process, PID:', this.process.pid) if (this.startTimeoutID) { clearTimeout(this.startTimeoutID) this.startTimeoutID = undefined } if(this.client){ this.client.destroy() this.client = undefined } if (this.process.pid) { let cmd = `kill -9 ${this.process.pid}`; if (process.platform === "win32") { cmd = `taskkill /pid ${this.process.pid} /t /f` } exec(cmd, (error) => { if (error) { Log.error('Failed to kill process:', error) } else { Log.info('Process killed successfully') } }) } } } function handleEngineData(data: any) { if(data.command === 'connect'){ captionEngine.connect() } else if(data.command === 'kill') { if(captionEngine.status !== 'stopped') { Log.warn('Error occurred, trying to kill caption engine...') captionEngine.kill() } } else if(data.command === 'caption') { allConfig.updateCaptionLog(data); } else if(data.command === 'translation') { allConfig.updateCaptionTranslation(data); } else if(data.command === 'print') { console.log(data.content) } else if(data.command === 'info') { Log.info('Engine Info:', data.content) } else if(data.command === 'warn') { Log.warn('Engine Warn:', data.content) } else if(data.command === 'error') { Log.error('Engine Error:', data.content) controlWindow.sendErrorMessage(/*i18n('engine.error') +*/ data.content) } else if(data.command === 'usage') { Log.info('Engine Token Usage: ', data.content) } else { Log.warn('Unknown command:', data) } } export const captionEngine = new CaptionEngine()