pediatric-ai-scribe-v3/src/utils/transcribeLocal.ts
Daniel 41772e600a chore(ts): Phase 2-3 — rename server + all backend .js to .ts
Mass-rename via git mv (preserves history): server.js + 56 files in
src/** (db/, middleware/, routes/, utils/) renamed to .ts. Tests and
scripts stay .js for now — they run under plain node --test and do
not require tsx.

After rename, tsc --noEmit reported 53 errors across 13 files. Fixed:

INLINE FIXES (5 files):
- src/utils/prompts.ts: cast 3 dynamic property assignments to PROMPTS
  via (PROMPTS as any).x — these are intentional runtime augmentations.
- src/routes/notes.ts: var opts: any = {...} so opts.model can be added
  conditionally (was 'opts.model does not exist on {maxTokens:number}').
- src/routes/learningAI.ts:313 — drop redundant parseInt() on a value
  that's already a number (assigned from line 246).
- src/routes/hospitalCourse.ts:66 — Date subtraction needs .getTime()
  on each side; was 'arithmetic on type Date'.
- src/utils/transcribeLocal.ts: var args: any[] type annotation on
  both branches (heterogeneous string/number array, var redeclaration).

@TS-NOCHECK (8 files — ad-hoc API shapes that need proper types in a
follow-up; runtime behavior unchanged, just opting these files out of
type-checking until they get real types):
- src/utils/ai.ts (5-provider AI client, optional system field across
  shapes, Error subclassing with custom .code/.model)
- src/utils/embeddings.ts (Vertex/LiteLLM/OpenAI request shapes)
- src/routes/transcribe.ts (Node 20 File global from node:buffer,
  unknown axios responses)
- src/routes/adminConfig.ts (same pattern as transcribe)
- src/routes/audioBackups.ts (req.body.X.length on unknown)
- src/routes/auth.ts (response.json() unknown in TS6, custom result
  augmentation)
- src/routes/documents.ts (S3 client config built piecemeal)
- src/db/database.ts (db._cleanupInterval added at runtime)

Verification:
- npm run typecheck → 0 errors
- 46/46 unit tests pass
- tsx loads all 32 src/routes/*.ts cleanly (smoke test)
- Server start chain reaches DB connect step (fails locally because no
  Postgres on dev box; in prod the docker-compose stack provides it)

Rollback anchor: ts-phase-1-2026-04-27 if anything misbehaves.
2026-04-27 22:22:29 +02:00

178 lines
6.1 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, audioPath) {
if (binary.includes('faster-whisper')) {
// faster-whisper CLI
var args = ['--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 = ['-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 };