/** * Whisper Service - Singleton persistent GPU speech-to-text server * Auto-starts with the system, auto-restarts on crash. * Single instance processes all client requests. */ import { join } from 'path' import { Subprocess } from 'bun' const WHISPER_PORT = 4104 const WHISPER_SCRIPT = join(import.meta.dir, '..', 'whisper_server.py') const RESTART_DELAY_MS = 3000 // Wait before auto-restart after crash interface WhisperState { enabled: boolean running: boolean starting: boolean process: Subprocess | null model: string device: string } const state: WhisperState = { enabled: true, // Always enabled by default running: false, starting: false, process: null, model: 'large-v3', device: 'cuda' } /** * Kill any process using the Whisper port */ async function killProcessOnPort(port: number): Promise { try { const proc = Bun.spawn(['powershell', '-Command', `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }` ], { stdout: 'ignore', stderr: 'ignore' }) await proc.exited await new Promise(resolve => setTimeout(resolve, 1000)) } catch { // Ignore errors } } /** * Check if Whisper WebSocket is ready using PowerShell */ async function checkPort(port: number): Promise { try { const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', `$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c) { Write-Output 'LISTENING' }` ], { stdout: 'pipe', stderr: 'ignore' }) const output = await new Response(proc.stdout).text() await proc.exited return output.trim() === 'LISTENING' } catch { return false } } /** * Monitor the Whisper process and auto-restart on crash */ function monitorProcess(proc: Subprocess) { proc.exited.then((exitCode) => { console.error(`[Whisper] Process exited with code ${exitCode}`) state.process = null state.running = false state.starting = false // Auto-restart after delay console.log(`[Whisper] Auto-restarting in ${RESTART_DELAY_MS / 1000}s...`) setTimeout(() => { startWhisperServer().catch(err => { console.error('[Whisper] Auto-restart failed:', err) }) }, RESTART_DELAY_MS) }) } /** * Start the Whisper Python server (singleton - only one instance) */ export async function startWhisperServer(): Promise { // Prevent multiple simultaneous start attempts if (state.starting) { console.log('[Whisper] Already starting, skipping') return false } // Already running if (state.running && state.process) { console.log('[Whisper] Already running') return true } // Check if an external instance is already listening const alreadyListening = await checkPort(WHISPER_PORT) if (alreadyListening) { console.log('[Whisper] External instance already running on port', WHISPER_PORT) state.running = true state.enabled = true return true } state.starting = true console.log(`[Whisper] Starting singleton server (${state.model})...`) // Kill any orphan process on the port await killProcessOnPort(WHISPER_PORT) try { const proc = Bun.spawn(['python', '-u', WHISPER_SCRIPT], { cwd: join(import.meta.dir, '..'), stdout: 'inherit', stderr: 'inherit', env: { ...process.env, PYTHONUNBUFFERED: '1' } }) state.process = proc // Monitor for crashes and auto-restart monitorProcess(proc) // Wait for initial startup await new Promise(resolve => setTimeout(resolve, 2000)) // Check if process died immediately if (proc.exitCode !== null) { console.error('[Whisper] Process exited immediately with code:', proc.exitCode) state.process = null state.starting = false return false } // Check if WebSocket is ready const isListening = await checkPort(WHISPER_PORT) if (isListening) { console.log('[Whisper] Server ready (GPU)') state.running = true state.enabled = true state.starting = false return true } // Wait for model loading (up to 120 seconds for large-v3) for (let i = 0; i < 40; i++) { await new Promise(resolve => setTimeout(resolve, 3000)) if (proc.exitCode !== null) { console.error('[Whisper] Process died during model loading') state.process = null state.starting = false return false } const ready = await checkPort(WHISPER_PORT) if (ready) { console.log('[Whisper] Server ready (GPU)') state.running = true state.enabled = true state.starting = false return true } } console.error('[Whisper] Timeout waiting for server (120s)') state.starting = false return false } catch (err: any) { console.error('[Whisper] Start error:', err.message) state.process = null state.starting = false return false } } /** * Stop the Whisper server (only for manual override, not used in normal flow) */ export function stopWhisperServer(): boolean { if (!state.process) { return true } try { state.process.kill() state.process = null state.running = false console.log('[Whisper] Stopped manually') return true } catch (err) { console.error('[Whisper] Stop error:', err) return false } } /** * Toggle is now a no-op for stop - Whisper always stays on. * If not running, triggers a start. */ export async function toggleWhisperServer(): Promise<{ enabled: boolean; success: boolean; starting: boolean }> { if (state.starting) { return { enabled: true, success: false, starting: true } } if (state.running) { // Already running - just confirm it's on return { enabled: true, success: true, starting: false } } // Not running - start it startWhisperServer().catch(err => { console.error('[Whisper] Start error:', err) state.starting = false }) return { enabled: true, success: true, starting: true } } /** * Get current Whisper state (checks real port status) */ export async function getWhisperState(): Promise<{ enabled: boolean running: boolean starting: boolean port: number model: string device: string }> { // Check if port is actually listening (skip if starting to avoid interference) if (!state.starting) { const isListening = await checkPort(WHISPER_PORT) // Sync state with reality if (isListening && !state.running) { state.running = true state.enabled = true } else if (!isListening && state.running) { state.running = false // Keep enabled=true since we auto-restart } } return { enabled: true, // Always enabled running: state.running, starting: state.starting, port: WHISPER_PORT, model: state.model, device: state.device } } /** * Check if Whisper is running */ export function isWhisperEnabled(): boolean { return state.running } export function getWhisperPort(): number { return WHISPER_PORT }