274 lines
7.0 KiB
TypeScript
274 lines
7.0 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
// 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
|
|
}
|