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.
178 lines
6.1 KiB
TypeScript
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 };
|