import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; export class WhisperTranscriber { options; logger; worker = null; readyPromise = null; resolveReady = null; rejectReady = null; pendingRequests = new Map(); constructor(options, logger) { this.options = options; this.logger = logger; } async transcribe(requestId, audioPath) { await this.ensureWorker(); if (!this.worker || this.worker.stdin.destroyed) { throw new Error('The Whisper worker is not available.'); } return new Promise((resolve, reject) => { this.pendingRequests.set(requestId, { resolve, reject }); try { this.worker?.stdin.write(`${JSON.stringify({ type: 'transcribe', requestId, audioPath })}\n`); } catch (error) { this.pendingRequests.delete(requestId); reject(error); } }); } async ensureWorker() { if (this.readyPromise) { return this.readyPromise; } this.worker = spawn(this.options.pythonExecutable, [ this.options.scriptPath, '--model', this.options.model, '--device', this.options.device, '--compute-type', this.options.computeType, ], { stdio: ['pipe', 'pipe', 'pipe'] }); this.readyPromise = new Promise((resolve, reject) => { this.resolveReady = resolve; this.rejectReady = reject; }); const stdout = createInterface({ input: this.worker.stdout }); stdout.on('line', (line) => { this.handleWorkerLine(line); }); this.worker.stderr.on('data', (chunk) => { const message = chunk.toString().trim(); if (message) { this.logger.warn({ whisperStderr: message }, 'Whisper worker stderr'); } }); this.worker.on('error', (error) => { this.failWorker(error instanceof Error ? error : new Error('The Whisper worker could not start.')); }); this.worker.on('exit', (code, signal) => { this.failWorker(new Error(`The Whisper worker exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`)); }); return this.readyPromise; } handleWorkerLine(line) { let payload; try { payload = JSON.parse(line); } catch { this.logger.warn({ whisperStdout: line }, 'Ignored non-JSON Whisper worker output'); return; } if (payload.type === 'ready') { this.logger.info({ model: payload.model }, 'Whisper worker ready'); this.resolveReady?.(); this.resolveReady = null; this.rejectReady = null; return; } if (payload.type === 'fatal') { this.failWorker(new Error(payload.message)); return; } if (payload.type === 'error') { if (!payload.requestId) { this.failWorker(new Error(payload.message)); return; } const pendingRequest = this.pendingRequests.get(payload.requestId); if (!pendingRequest) { return; } this.pendingRequests.delete(payload.requestId); pendingRequest.reject(new Error(payload.message)); return; } const pendingRequest = this.pendingRequests.get(payload.requestId); if (!pendingRequest) { return; } this.pendingRequests.delete(payload.requestId); pendingRequest.resolve(payload.text.trim()); } failWorker(error) { if (this.worker) { this.worker.removeAllListeners(); this.worker = null; } this.rejectReady?.(error); this.resolveReady = null; this.rejectReady = null; this.readyPromise = null; for (const { reject } of this.pendingRequests.values()) { reject(error); } this.pendingRequests.clear(); this.logger.error({ err: error }, 'Whisper worker failed'); } }