- Add local Whisper (whisper.cpp / faster-whisper) as transcription provider Set TRANSCRIBE_PROVIDER=local with configurable model size and binary path - Upgrade all refine/instruction inputs to resizable textareas across encounter, dictation, hospital course, chart review, well visit, sick visit - Make AI memory injection flexible: physician preferences and corrections are now actively applied (not just "formatting reference"), while still overridable by current prompt instructions
178 lines
6.1 KiB
JavaScript
178 lines
6.1 KiB
JavaScript
// ============================================================
|
|
// 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 };
|