122 lines
4.2 KiB
JavaScript
122 lines
4.2 KiB
JavaScript
|
|
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');
|
||
|
|
}
|
||
|
|
}
|