← 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
M lib/rc.jsA lib/transcribe.mjsA models/ggml-small.en.binM sync-calls.mjs
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 →