pediatric-ai-scribe-v3/src/utils/transcribeLocal.ts
Daniel 6fa0d87da4 refactor(ts): day 4 — middleware + utils + db .js → .ts (24 files)
All remaining backend files renamed:
  src/middleware/auth.ts, logging.ts (2 files)
  src/utils/*.ts         (20 files: ai, auditQueue, config, crypto,
                          embeddings, errors, fileType, logger, models,
                          notify, passwords, platform, promptSafe,
                          prompts, redact, sessions, transcribe*,
                          ttsGoogle)
  src/db/database.ts, migrate.ts (2 files)

Spot-fixes to satisfy tsc (all within the spirit of 'no behavior
change' — added `: any` annotations where the original JS relied on
duck typing that tsc's default inference narrows too aggressively):

  utils/ai.ts — body, converseParams, request literals + fallback
    result object + err.code/model/message casts. AI client has lots
    of provider-specific ad-hoc object shapes; Day 5 will replace the
    `any`s with proper provider-response interfaces.
  utils/embeddings.ts — payload + request as `any`; generateEmbedding
    call sites pass `undefined as any` for the now-required second
    arg (model) until we refactor the signature.
  utils/prompts.ts — PROMPTS typed as Record<string, any> so
    .loadFromDb / .updatePrompt / .getAllPrompts attachments after
    the const literal compile.
  utils/transcribeLocal.ts — buildArgs() has two `var args = [...]`
    in the same function scope (var-hoisted); both now typed as
    any[] so they don't type-clash across conditionals.

Backend is now 54 of 54 TypeScript files, permissive mode.
`npm run typecheck` EXIT 0. Prod container still running the old
JS image — no Dockerfile change yet.

Next: Day 5 flips strict: true, fixes every error tsc surfaces, adds
Vitest + Zod + Knip tooling.
2026-04-23 19:52:16 +02:00

178 lines
6.2 KiB
TypeScript

// ============================================================
// LOCAL WHISPER TRANSCRIPTION — uses whisper.cpp or faster-whisper
// ============================================================
// Requires: whisper.cpp binary or faster-whisper-cli installed on the host.
// Set TRANSCRIBE_PROVIDER=local and optionally WHISPER_MODEL_SIZE, WHISPER_BINARY.
var { execFile } = require('child_process');
var fs = require('fs');
var path = require('path');
var os = require('os');
var crypto = require('crypto');
var logger = require('./logger');
var WHISPER_BINARY = process.env.WHISPER_BINARY || 'whisper-cpp';
var WHISPER_MODEL_SIZE = process.env.WHISPER_MODEL_SIZE || 'small';
var WHISPER_MODEL_PATH = process.env.WHISPER_MODEL_PATH || '';
var WHISPER_LANGUAGE = process.env.WHISPER_LANGUAGE || 'en';
var WHISPER_THREADS = process.env.WHISPER_THREADS || String(Math.max(2, os.cpus().length - 1));
/**
* Check if local Whisper is available
*/
function isLocalWhisperConfigured() {
return process.env.TRANSCRIBE_PROVIDER === 'local';
}
/**
* Detect which whisper binary is available
* Supports: whisper-cpp, whisper, faster-whisper
*/
function detectBinary(callback) {
var candidates = [WHISPER_BINARY, 'whisper-cpp', 'whisper', 'faster-whisper'];
var tried = 0;
function tryNext() {
if (tried >= candidates.length) return callback(null);
var bin = candidates[tried++];
execFile(bin, ['--help'], { timeout: 5000 }, function(err) {
if (!err || (err.code !== 'ENOENT' && err.code !== 127)) return callback(bin);
tryNext();
});
}
tryNext();
}
/**
* Convert audio buffer to WAV using ffmpeg (whisper.cpp needs 16kHz mono WAV)
*/
function convertToWav(inputPath, callback) {
var wavPath = inputPath + '.wav';
execFile('ffmpeg', [
'-i', inputPath,
'-ar', '16000',
'-ac', '1',
'-f', 'wav',
'-y', wavPath
], { timeout: 30000 }, function(err) {
if (err) return callback(err, null);
callback(null, wavPath);
});
}
/**
* Transcribe audio buffer using local Whisper
* @param {Buffer} audioBuffer - Audio data
* @param {string} mimeType - MIME type of the audio
* @returns {Promise<string>} Transcribed text
*/
function transcribeWithLocal(audioBuffer, mimeType) {
return new Promise(function(resolve, reject) {
// Write buffer to temp file
var ext = '.webm';
if (mimeType && mimeType.includes('wav')) ext = '.wav';
else if (mimeType && mimeType.includes('mp3')) ext = '.mp3';
else if (mimeType && mimeType.includes('ogg')) ext = '.ogg';
else if (mimeType && mimeType.includes('mp4')) ext = '.mp4';
var tmpDir = os.tmpdir();
var tmpFile = path.join(tmpDir, 'whisper_' + crypto.randomBytes(8).toString('hex') + ext);
fs.writeFile(tmpFile, audioBuffer, function(err) {
if (err) return reject(new Error('Failed to write temp audio: ' + err.message));
// Convert to WAV for whisper.cpp compatibility
convertToWav(tmpFile, function(convErr, wavPath) {
var audioPath = convErr ? tmpFile : wavPath;
detectBinary(function(binary) {
if (!binary) {
cleanup(tmpFile, wavPath);
return reject(new Error(
'Local Whisper not found. Install whisper.cpp (https://github.com/ggerganov/whisper.cpp) ' +
'or faster-whisper (pip install faster-whisper). Set WHISPER_BINARY env var if using a custom path.'
));
}
var args = buildArgs(binary, audioPath);
logger.info('[LocalWhisper] Running: ' + binary + ' ' + args.join(' '));
execFile(binary, args, { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, function(execErr, stdout, stderr) {
cleanup(tmpFile, wavPath);
if (execErr) {
logger.error('[LocalWhisper] Error:', execErr.message);
if (stderr) logger.error('[LocalWhisper] stderr:', stderr.substring(0, 500));
return reject(new Error('Local Whisper transcription failed: ' + execErr.message));
}
// Parse output — whisper.cpp outputs timestamped lines like [00:00:00.000 --> 00:00:05.000] text
var text = parseOutput(stdout);
if (!text || !text.trim()) {
return reject(new Error('Local Whisper returned empty transcription'));
}
resolve(text.trim());
});
});
});
});
});
}
/**
* Build command-line args based on the detected binary
*/
function buildArgs(binary: string, audioPath: string): any[] {
if (binary.includes('faster-whisper')) {
// faster-whisper CLI
var args: any[] = ['--model', WHISPER_MODEL_SIZE, '--language', WHISPER_LANGUAGE];
if (WHISPER_THREADS) args.push('--threads', WHISPER_THREADS);
args.push(audioPath);
return args;
}
// whisper.cpp style
var args: any[] = ['-f', audioPath, '-l', WHISPER_LANGUAGE, '-t', WHISPER_THREADS, '--no-timestamps'];
if (WHISPER_MODEL_PATH) {
args.push('-m', WHISPER_MODEL_PATH);
} else {
// whisper.cpp uses model names like ggml-small.bin
args.push('-m', 'models/ggml-' + WHISPER_MODEL_SIZE + '.bin');
}
// Medical vocabulary hint via initial prompt
args.push('--prompt', 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.');
return args;
}
/**
* Parse whisper output into clean text
*/
function parseOutput(stdout) {
if (!stdout) return '';
// whisper.cpp with --no-timestamps outputs plain text
// whisper.cpp with timestamps: [00:00:00.000 --> 00:00:05.000] Hello world
var lines = stdout.split('\n');
var text = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Strip timestamp prefix if present
var match = line.match(/^\[[\d:.]+\s*-->\s*[\d:.]+\]\s*(.*)$/);
if (match) {
text.push(match[1].trim());
} else if (line.trim() && !line.startsWith('whisper_') && !line.startsWith('main:')) {
// Plain text line (skip whisper.cpp log lines)
text.push(line.trim());
}
}
return text.join(' ');
}
/**
* Clean up temp files
*/
function cleanup(tmpFile, wavPath) {
try { fs.unlinkSync(tmpFile); } catch(e) {}
if (wavPath) { try { fs.unlinkSync(wavPath); } catch(e) {} }
}
module.exports = { transcribeWithLocal, isLocalWhisperConfigured };