[object Object]

← back to Rc Fm Callnotes

auto-save: 2026-07-03T04:54:58 (4 files) — lib/rc.js sync-calls.mjs lib/transcribe.mjs models/

8d72e96ae06b48b9a8361f38687ae81e9ffca0f4 · 2026-07-03 04:55:18 -0700 · Steve Abrams

Files touched

Diff

commit 8d72e96ae06b48b9a8361f38687ae81e9ffca0f4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 3 04:55:18 2026 -0700

    auto-save: 2026-07-03T04:54:58 (4 files) — lib/rc.js sync-calls.mjs lib/transcribe.mjs models/
---
 lib/rc.js                |   8 +++++
 lib/transcribe.mjs       |  87 +++++++++++++++++++++++++++++++++++++++++++++++
 models/ggml-small.en.bin | Bin 0 -> 487614201 bytes
 sync-calls.mjs           |   1 +
 4 files changed, 96 insertions(+)

diff --git a/lib/rc.js b/lib/rc.js
index 5e748d9..4bfe48e 100644
--- a/lib/rc.js
+++ b/lib/rc.js
@@ -69,3 +69,11 @@ export function createContact(contact) {
 export function deleteContact(id) {
   return rc(`/restapi/v1.0/account/~/extension/~/address-book/contact/${id}`, { method: 'DELETE' });
 }
+
+// Raw media download (call recordings, voicemail audio). Returns a Buffer.
+export async function downloadMedia(uri) {
+  const token = await getToken();
+  const res = await fetch(uri, { headers: { Authorization: `Bearer ${token}` } });
+  if (!res.ok) throw new Error(`RC media ${uri.split('/account/')[1] || uri} -> ${res.status}`);
+  return Buffer.from(await res.arrayBuffer());
+}
diff --git a/lib/transcribe.mjs b/lib/transcribe.mjs
new file mode 100644
index 0000000..33d5262
--- /dev/null
+++ b/lib/transcribe.mjs
@@ -0,0 +1,87 @@
+// 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 };
+}
diff --git a/models/ggml-small.en.bin b/models/ggml-small.en.bin
new file mode 100644
index 0000000..1d7f367
Binary files /dev/null and b/models/ggml-small.en.bin differ
diff --git a/sync-calls.mjs b/sync-calls.mjs
index 4229bee..cb7ebd7 100644
--- a/sync-calls.mjs
+++ b/sync-calls.mjs
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { callLog, listContacts, createContact } from './lib/rc.js';
+import { enrichWithTranscript } from './lib/transcribe.mjs';
 import {
   findClientByPhone, appendClientNote, createClientFromCall,
   findInvoiceByNumber, appendInvoiceInternalNote, invoiceNotesOf, normPhone,

← 300fc6f call-notes sync live: client match/create, invoice notes, RC  ·  back to Rc Fm Callnotes  ·  chore: macstudio3 migration — reconcile from mac2 + repoint b8d038a →