← 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 };