← back to Rc Fm Callnotes
lib/transcribe.mjs
88 lines
// Local ($0) call transcription + summarization — the RingSense workaround.
// Recording MP3 (already licensed via the standard RC API) -> ffmpeg 16k WAV ->
// whisper.cpp (small.en, on-box) -> Ollama summary (on-box). Audio and text
// never leave this Mac; temp files are deleted after each call.
import { execFileSync } from 'node:child_process';
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { rc, downloadMedia } from './rc.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const FFMPEG = process.env.FFMPEG_BIN || '/opt/homebrew/bin/ffmpeg';
const WHISPER = process.env.WHISPER_BIN || '/opt/homebrew/bin/whisper-cli';
const MODEL = process.env.WHISPER_MODEL || join(__dir, '..', 'models', 'ggml-small.en.bin');
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const LLM = process.env.SUMMARY_MODEL || 'qwen2.5:latest';
const MAX_SECS = Number(process.env.TRANSCRIBE_MAX_SECS || 1800); // skip calls > 30 min
const MIN_SECS = 15; // below this there's nothing worth transcribing
export function transcribeAvailable() {
return existsSync(FFMPEG) && existsSync(WHISPER) && existsSync(MODEL);
}
function audioToTranscript(buf, callId) {
const mp3 = `/tmp/rcfm-${callId}.mp3`;
const wav = `/tmp/rcfm-${callId}.wav`;
try {
writeFileSync(mp3, buf);
execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', mp3, '-ar', '16000', '-ac', '1', wav]);
const text = execFileSync(WHISPER, ['-m', MODEL, '-f', wav, '--no-timestamps'], {
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024, timeout: 10 * 60 * 1000,
}).toString().replace(/\s+/g, ' ').trim();
return text;
} finally {
for (const f of [mp3, wav]) { try { unlinkSync(f); } catch {} }
}
}
async function summarize(transcript) {
const prompt = 'Summarize this phone call for a CRM call note. 2-4 sentences: who called about what, '
+ 'key facts (order numbers, invoice numbers, SKUs, addresses, amounts), and any follow-up needed. '
+ 'Plain text only, no preamble.\n\nTRANSCRIPT:\n' + transcript;
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: LLM, prompt, stream: false, think: false, options: { temperature: 0.2, num_predict: 300 } }),
signal: AbortSignal.timeout(8 * 60 * 1000),
});
if (!res.ok) throw new Error(`Ollama ${res.status}`);
const json = await res.json();
return (json.response || '').replace(/<think>[\s\S]*?<\/think>/, '').trim();
}
// Voicemail audio lives in the message store, not the recording endpoint.
async function voicemailAudioUri(rec) {
const msgUri = rec.message?.uri;
if (!msgUri) return null;
const msg = await rc(msgUri.replace(/^https?:\/\/[^/]+/, ''));
const att = (msg.attachments || []).find((a) => a.type === 'AudioRecording');
return att?.uri || null;
}
// Enrich a call-log record in place: sets rec._transcript (full text, for invoice
// detection) and rec._noteText (the CRM summary). Any failure leaves the record
// untouched so the metadata-only note still lands — transcription never blocks the sync.
export async function enrichWithTranscript(rec) {
if (!transcribeAvailable()) return { skipped: 'tooling missing' };
const dur = rec.duration || 0;
if (dur < MIN_SECS || dur > MAX_SECS) return { skipped: `duration ${dur}s out of range` };
let audioUri = rec.recording?.contentUri || null;
let kind = 'recording';
if (!audioUri && rec.result === 'Voicemail') {
audioUri = await voicemailAudioUri(rec).catch(() => null);
kind = 'voicemail';
}
if (!audioUri) return { skipped: 'no audio' };
const buf = await downloadMedia(audioUri);
const transcript = audioToTranscript(buf, rec.id);
if (!transcript || transcript.length < 20) return { skipped: 'empty transcript' };
rec._transcript = transcript;
const summary = await summarize(transcript).catch(() => null);
rec._noteText = summary
? `${kind === 'voicemail' ? 'Voicemail' : 'Call'} summary (AI, transcribed locally): ${summary}`
: `Transcript excerpt: ${transcript.slice(0, 400)}`;
return { ok: kind, chars: transcript.length };
}