// ============================================================ // 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} 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 };