← back to Butlr
lib/transcribe.js
160 lines
// Whisper transcription. Local-first per Steve's standing rules.
//
// PRIMARY: whisper.cpp (offline, Apple-Silicon Metal, ~1.5s per minute of audio)
// FALLBACK: OpenAI Whisper API (only used when local binary missing or fails)
//
// Triggered by the Twilio recording-complete webhook (recording_url comes back
// on call.completed). This module fetches the Twilio MP3, runs whisper-cpp on
// it locally, and writes a JSON transcript to disk. Falls back to OpenAI's
// cloud Whisper only if the local pipeline is unavailable. The local path is
// quota-immune and free, which is what got us through the 2026-05-12 quota 429
// when the MacEnthusiasts test call landed.
const fs = require('fs');
const path = require('path');
const { execFile, spawn } = require('child_process');
const { promisify } = require('util');
const execFileP = promisify(execFile);
const API_KEY = process.env.OPENAI_API_KEY || '';
const MODEL = process.env.WHISPER_MODEL || 'whisper-1';
const WHISPER_CLI = process.env.WHISPER_CLI_PATH || '/opt/homebrew/bin/whisper-cli';
const FFMPEG = process.env.FFMPEG_PATH || '/opt/homebrew/bin/ffmpeg';
const WHISPER_MODEL_PATH = process.env.WHISPER_MODEL_PATH
|| path.join(process.env.HOME || '', '.cache/whisper-cpp/ggml-base.en.bin');
const TRANSCRIPTS_DIR = path.join(__dirname, '..', 'data', 'transcripts');
const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'recordings');
fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
// Download a Twilio recording (basic auth required) to local disk.
// Prefers API Key (SK.../secret) over master Auth Token, like lib/twilio.js.
async function downloadRecording(recordingUrl, callId) {
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const keySid = process.env.TWILIO_API_KEY_SID;
const keySecret = process.env.TWILIO_API_KEY_SECRET;
const authToken = process.env.TWILIO_AUTH_TOKEN;
let user, pass;
if (keySid && keySecret) { user = keySid; pass = keySecret; }
else if (accountSid && authToken) { user = accountSid; pass = authToken; }
else return { ok: false, error: 'no_twilio_creds' };
// Twilio recording URL format: append .mp3 to force MP3 download
const url = recordingUrl.endsWith('.mp3') ? recordingUrl : recordingUrl + '.mp3';
const auth = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
try {
const resp = await fetch(url, { headers: { 'Authorization': auth } });
if (!resp.ok) return { ok: false, error: `twilio_http_${resp.status}` };
const buf = Buffer.from(await resp.arrayBuffer());
const dest = path.join(RECORDINGS_DIR, `${callId}.mp3`);
fs.writeFileSync(dest, buf);
return { ok: true, path: dest, bytes: buf.length };
} catch (e) {
return { ok: false, error: 'download_exception', detail: e.message };
}
}
// Run an MP3 through whisper.cpp locally. Returns { ok, path, text } on success.
// Requires: ffmpeg + whisper-cli + ggml-base.en.bin model. ~1.5s per audio-minute on M2.
async function transcribeLocal(callId, mp3Path) {
if (!fs.existsSync(mp3Path)) return { ok: false, error: 'mp3_not_found' };
if (!fs.existsSync(WHISPER_CLI)) return { ok: false, error: 'whisper_cli_missing' };
if (!fs.existsSync(FFMPEG)) return { ok: false, error: 'ffmpeg_missing' };
if (!fs.existsSync(WHISPER_MODEL_PATH)) return { ok: false, error: 'whisper_model_missing', hint: WHISPER_MODEL_PATH };
const wavPath = path.join('/tmp', `${callId}-${Date.now()}.wav`);
try {
// 1) MP3 → 16kHz mono PCM16 WAV (whisper.cpp requires this format)
await execFileP(FFMPEG, ['-y', '-i', mp3Path, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wavPath], { timeout: 30_000 });
// 2) Transcribe
const { stdout } = await execFileP(WHISPER_CLI, ['-m', WHISPER_MODEL_PATH, '-f', wavPath, '--no-timestamps', '-t', '4', '-otxt', '-of', wavPath.replace(/\.wav$/, '')], { timeout: 180_000, maxBuffer: 8 * 1024 * 1024 });
const txtPath = wavPath.replace(/\.wav$/, '.txt');
let text = '';
if (fs.existsSync(txtPath)) { text = fs.readFileSync(txtPath, 'utf8').trim(); fs.unlinkSync(txtPath); }
else { text = stdout.split('\n').filter(l => l && !l.startsWith('whisper_') && !l.startsWith('main:') && !l.startsWith('ggml_') && !l.startsWith('system_info')).join('\n').trim(); }
const payload = {
text,
language: 'en',
source: 'whisper.cpp/ggml-base.en',
transcribed_at: new Date().toISOString(),
};
const dest = path.join(TRANSCRIPTS_DIR, `${callId}.json`);
fs.writeFileSync(dest, JSON.stringify(payload, null, 2));
return { ok: true, path: dest, text, segments: 1, source: 'whisper.cpp' };
} catch (e) {
return { ok: false, error: 'whisper_cpp_failed', detail: e.message };
} finally {
try { fs.existsSync(wavPath) && fs.unlinkSync(wavPath); } catch {}
}
}
// Run an MP3 through OpenAI Whisper as cloud fallback. Returns the JSON response with `text` +
// segments + words (if requested). Saves to data/transcripts/<callId>.json.
async function transcribeRemote(callId, mp3Path) {
if (!API_KEY) return { ok: false, error: 'no_openai_key' };
if (!fs.existsSync(mp3Path)) return { ok: false, error: 'mp3_not_found' };
try {
const audio = fs.readFileSync(mp3Path);
// node 20+ has FormData + Blob
const form = new FormData();
form.append('file', new Blob([audio], { type: 'audio/mpeg' }), `${callId}.mp3`);
form.append('model', MODEL);
form.append('response_format', 'verbose_json');
form.append('timestamp_granularities[]', 'segment');
const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: form,
});
if (!resp.ok) {
const body = await resp.text().catch(() => '');
return { ok: false, error: `whisper_http_${resp.status}`, body: body.slice(0, 400) };
}
const json = await resp.json();
const dest = path.join(TRANSCRIPTS_DIR, `${callId}.json`);
json.source = 'openai-whisper-1';
fs.writeFileSync(dest, JSON.stringify(json, null, 2));
return { ok: true, path: dest, text: json.text, segments: (json.segments || []).length, source: 'openai' };
} catch (e) {
return { ok: false, error: 'whisper_exception', detail: e.message };
}
}
// Local-first dispatcher. Tries whisper.cpp; falls back to OpenAI Whisper.
async function transcribe(callId, mp3Path) {
const local = await transcribeLocal(callId, mp3Path);
if (local.ok) return local;
// Local failed — log why, then try remote.
console.warn('[transcribe] local whisper.cpp failed:', local.error, local.detail || local.hint || '');
const remote = await transcribeRemote(callId, mp3Path);
if (remote.ok) return remote;
// Neither worked — return the local error since that's what we prefer to fix.
return { ok: false, error: 'both_failed', local: local.error, remote: remote.error, body: remote.body };
}
// One-shot: download + transcribe. Called from the Twilio status callback
// when call.completed fires with a RecordingUrl.
async function recordAndTranscribe(callId, recordingUrl) {
const dl = await downloadRecording(recordingUrl, callId);
if (!dl.ok) return { ok: false, step: 'download', ...dl };
const tx = await transcribe(callId, dl.path);
if (!tx.ok) return { ok: false, step: 'transcribe', ...tx, mp3Path: dl.path };
return { ok: true, mp3Path: dl.path, transcriptPath: tx.path, text: tx.text };
}
function readTranscript(callId) {
const p = path.join(TRANSCRIPTS_DIR, `${callId}.json`);
if (!fs.existsSync(p)) return null;
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
}
function recordingExists(callId) {
return fs.existsSync(path.join(RECORDINGS_DIR, `${callId}.mp3`));
}
module.exports = { downloadRecording, transcribe, recordAndTranscribe, readTranscript, recordingExists, TRANSCRIPTS_DIR, RECORDINGS_DIR };