Files
PrivateChat/server/dist/whisper-transcriber.js

122 lines
4.2 KiB
JavaScript
Raw Normal View History

2026-03-11 00:26:49 +01:00
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');
}
}