← back to Butlr
transcribe: whisper.cpp PRIMARY, OpenAI Whisper FALLBACK
2c7bdced3a807a844e7a96280538fc27a7b7031a · 2026-05-12 14:50:04 -0700 · Steve
Reason — 2026-05-12 MacEnthusiasts test call (CAb64d5b1ee, 93s) landed
fine, MP3 saved, but OpenAI Whisper returned 429 insufficient_quota and
left us blind. whisper-cpp on the local M2 Max transcribed the same file
in 1.6s. Local-first removes the OpenAI quota dependency entirely.
transcribeLocal() — runs ffmpeg → whisper-cli with ggml-base.en model.
transcribeRemote() — original OpenAI path, kept as a fallback only.
transcribe() — dispatcher: try local, fall back to remote, error if both fail.
Env knobs (all optional, sensible Homebrew defaults):
WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli
FFMPEG_PATH=/opt/homebrew/bin/ffmpeg
WHISPER_MODEL_PATH=~/.cache/whisper-cpp/ggml-base.en.bin
Files touched
M data/businesses.jsonA data/recordings/D5y8T2YV.mp3A data/recordings/gdRSfYO5.mp3A data/transcripts/D5y8T2YV-test.jsonA data/transcripts/D5y8T2YV.jsonM lib/transcribe.jsM public/css/site.cssM routes/public.jsM routes/twilio-webhooks.jsA views/public/call-live.ejs
Diff
commit 2c7bdced3a807a844e7a96280538fc27a7b7031a
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 12 14:50:04 2026 -0700
transcribe: whisper.cpp PRIMARY, OpenAI Whisper FALLBACK
Reason — 2026-05-12 MacEnthusiasts test call (CAb64d5b1ee, 93s) landed
fine, MP3 saved, but OpenAI Whisper returned 429 insufficient_quota and
left us blind. whisper-cpp on the local M2 Max transcribed the same file
in 1.6s. Local-first removes the OpenAI quota dependency entirely.
transcribeLocal() — runs ffmpeg → whisper-cli with ggml-base.en model.
transcribeRemote() — original OpenAI path, kept as a fallback only.
transcribe() — dispatcher: try local, fall back to remote, error if both fail.
Env knobs (all optional, sensible Homebrew defaults):
WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli
FFMPEG_PATH=/opt/homebrew/bin/ffmpeg
WHISPER_MODEL_PATH=~/.cache/whisper-cpp/ggml-base.en.bin
---
data/businesses.json | 7 +-
data/recordings/D5y8T2YV.mp3 | Bin 0 -> 295706 bytes
data/recordings/gdRSfYO5.mp3 | Bin 0 -> 68336 bytes
data/transcripts/D5y8T2YV-test.json | 6 ++
data/transcripts/D5y8T2YV.json | 7 ++
lib/transcribe.js | 77 +++++++++++++---
public/css/site.css | 73 +++++++++++++++
routes/public.js | 78 +++++++++++++++-
routes/twilio-webhooks.js | 41 ++++++++-
views/public/call-live.ejs | 175 ++++++++++++++++++++++++++++++++++++
10 files changed, 446 insertions(+), 18 deletions(-)
diff --git a/data/businesses.json b/data/businesses.json
index 7b14be1..6a34dce 100644
--- a/data/businesses.json
+++ b/data/businesses.json
@@ -46,7 +46,12 @@
"name": "Wells Fargo Credit Card",
"phone": "1-800-642-4720",
"category_hints": ["credit-card-increase", "credit-card-dispute"],
- "ivr_notes": "Say 'customer service' or press 0."
+ "ivr_notes": "Say 'customer service' or press 0.",
+ "ivr_dtmf": [
+ { "delay_sec": 8, "digit": "0" },
+ { "delay_sec": 6, "digit": "0" },
+ { "delay_sec": 5, "digit": "0" }
+ ]
},
{
"slug": "cedars-sinai-billing",
diff --git a/data/recordings/D5y8T2YV.mp3 b/data/recordings/D5y8T2YV.mp3
new file mode 100644
index 0000000..5cb0183
Binary files /dev/null and b/data/recordings/D5y8T2YV.mp3 differ
diff --git a/data/recordings/gdRSfYO5.mp3 b/data/recordings/gdRSfYO5.mp3
new file mode 100644
index 0000000..30123f6
Binary files /dev/null and b/data/recordings/gdRSfYO5.mp3 differ
diff --git a/data/transcripts/D5y8T2YV-test.json b/data/transcripts/D5y8T2YV-test.json
new file mode 100644
index 0000000..2416a58
--- /dev/null
+++ b/data/transcripts/D5y8T2YV-test.json
@@ -0,0 +1,6 @@
+{
+ "text": "There, you can set up a pickup or delivery as well. If you know your party's extension, you may enter it now. For rentals press 1, for sales press 2, for service press 3, for accounting and admin press 4, for address and store hours, press 5. For company directory, press star. To repeat this message, press the pound key.\n Thank you for calling Mac enthusiasts. We are open at the rear entrance of our store. Our business hours are 9.30 a.m. to 6 p.m. Monday through Friday. For rental quotes or new service requests, please visit our website at macenthusius.com. There, you can set up a pickup or delivery as well. If you know your party's extension, you may enter it now. For rentals press 1, for sales press 2, for service press 3, for accounting and admin press 4, for\n address and store hours, press 5. For company directory, press star. To repeat this message, press the pound key.",
+ "language": "en",
+ "source": "whisper.cpp/ggml-base.en",
+ "transcribed_at": "2026-05-12T21:50:04.298Z"
+}
\ No newline at end of file
diff --git a/data/transcripts/D5y8T2YV.json b/data/transcripts/D5y8T2YV.json
new file mode 100644
index 0000000..d8f2259
--- /dev/null
+++ b/data/transcripts/D5y8T2YV.json
@@ -0,0 +1,7 @@
+{
+ "text": "There, you can set up a pickup or delivery as well. If you know your party's extension, you may enter it now. For rentals press 1, for sales press 2, for service press 3, for accounting and admin press 4, for address and store hours, press 5. For company directory, press star. To repeat this message, press the pound key. Thank you for calling Mac enthusiasts. We are open at the rear entrance of our store. Our business hours are 9:30 AM to 6 PM Monday through Friday. For rental quotes or new service requests, please visit our website at macenthusius.com. There, you can set up a pickup or delivery as well. If you know your party's extension, you may enter it now. For rentals press 1, for sales press 2, for service press 3, for accounting and admin press 4, for address and store hours, press 5. For company directory, press star. To repeat this message, press the pound key.",
+ "language": "en",
+ "duration": 73.9,
+ "source": "whisper.cpp ggml-base.en — local fallback (OpenAI quota 429)",
+ "transcribed_at": "2026-05-12T21:46:00Z"
+}
diff --git a/lib/transcribe.js b/lib/transcribe.js
index e07a8de..65a28fc 100644
--- a/lib/transcribe.js
+++ b/lib/transcribe.js
@@ -1,18 +1,27 @@
-// Whisper transcription via OpenAI API.
+// Whisper transcription. Local-first per Steve's standing rules.
//
-// Standing rule (Steve, 2026-05-12): "record all conversations and create
-// transcript when finished." Recording happens via Twilio Recording API
-// (recording_url comes back on call.completed). This module fetches that
-// MP3, ships it to Whisper, and writes the JSON transcript to disk.
+// 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)
//
-// Whisper outputs an SRT-style JSON with timestamps + speaker turns. We
-// store the raw response so we can re-render the UI later without re-paying.
+// 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');
@@ -46,9 +55,44 @@ async function downloadRecording(recordingUrl, callId) {
}
}
-// Run an MP3 through Whisper. Returns the JSON response with `text` +
+// 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 transcribe(callId, mp3Path) {
+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' };
@@ -72,13 +116,26 @@ async function transcribe(callId, mp3Path) {
}
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 };
+ 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) {
diff --git a/public/css/site.css b/public/css/site.css
index d95f8c2..8422ad5 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -410,3 +410,76 @@ html[data-theme='dark'] .theme-toggle-sun { display: none; }
.social-claim-list li a { display: block; font-weight: 500; }
.social-claim-list li span.muted { display: block; margin-top: .25rem; }
.social-claim-list code { background: var(--bg-alt); padding: .1rem .4rem; border-radius: 4px; font-size: .85em; }
+
+/* Live call popup-grid layout */
+.live-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
+ gap: 1.5rem;
+ margin-top: 1rem;
+}
+@media (max-width: 880px) { .live-grid { grid-template-columns: 1fr; } }
+
+.live-status-card, .live-chat-card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 1.5rem; box-shadow: var(--shadow);
+ display: flex; flex-direction: column; gap: .75rem;
+}
+
+.live-status-head { display: flex; align-items: center; gap: 1rem; justify-content: space-between; flex-wrap: wrap; }
+.live-status-head h1 { font-size: 1.6rem; margin: 0; line-height: 1.2; }
+.live-status-head .status { font-size: .85rem; padding: .35rem .7rem; }
+
+.live-timeline {
+ display: flex; flex-direction: column; gap: .5rem; margin-top: 1rem;
+ padding: 1rem; background: var(--bg-alt); border-radius: var(--radius);
+}
+.live-stage {
+ display: flex; align-items: center; gap: .55rem;
+ font-size: .95rem; color: var(--text-muted);
+ transition: color .2s;
+}
+.live-stage-dot {
+ display: inline-block; width: 11px; height: 11px; border-radius: 50%;
+ background: var(--border); border: 1px solid var(--border);
+ transition: background .25s, transform .25s;
+}
+.live-stage.done { color: var(--text); }
+.live-stage.done .live-stage-dot { background: var(--green); border-color: var(--green); }
+.live-stage.current { color: var(--text); font-weight: 600; }
+.live-stage.current .live-stage-dot { background: var(--accent); border-color: var(--accent); transform: scale(1.4); box-shadow: 0 0 0 4px rgba(37,69,179,.18); animation: pulse 1.5s infinite; }
+@keyframes pulse { 0%,100%{box-shadow:0 0 0 4px rgba(37,69,179,.18)} 50%{box-shadow:0 0 0 8px rgba(37,69,179,.30)} }
+
+.live-elapsed { font-family: ui-monospace, Menlo, monospace; font-size: 1.1rem; color: var(--text-muted); margin-top: .5rem; }
+.live-elapsed span { color: var(--text); font-weight: 600; }
+.live-goal-block { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
+.live-goal-block h3 { font-size: 1.05rem; margin: 0 0 .25rem; }
+.live-actions { display: flex; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
+
+/* Chat panel */
+.live-chat-card { max-height: 640px; min-height: 480px; }
+.live-chat-head { display: flex; align-items: center; gap: .75rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border); margin-bottom: .25rem; }
+.live-chat-head .brand-mark { width: 36px; height: 36px; }
+.live-chat-head strong { display: block; font-family: var(--font-serif); font-size: 1.1rem; }
+
+.live-chat-messages {
+ flex: 1; overflow-y: auto;
+ padding: .75rem .25rem;
+ display: flex; flex-direction: column; gap: .6rem;
+}
+.chat-msg {
+ padding: .65rem .85rem; border-radius: 8px; font-size: .92rem; line-height: 1.45;
+ background: var(--bg-alt); border: 1px solid var(--border);
+}
+.chat-msg.you { align-self: flex-end; max-width: 85%; background: rgba(37,69,179,.08); border-color: var(--accent); }
+.chat-msg.butlr { align-self: flex-start; max-width: 92%; }
+.chat-msg.system { align-self: center; background: transparent; border: none; color: var(--text-muted); font-size: .85rem; padding: .25rem 0; }
+.chat-msg strong { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: .25rem; }
+.chat-msg p { margin: 0; white-space: pre-wrap; }
+
+.live-chat-form { display: flex; gap: .5rem; padding-top: .75rem; border-top: 1px solid var(--border); }
+.live-chat-form textarea {
+ flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: .65rem .75rem; font: inherit; resize: none;
+}
+.live-chat-form button { align-self: stretch; }
diff --git a/routes/public.js b/routes/public.js
index b303608..1689360 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -136,8 +136,8 @@ router.post('/new/callback', (req, res) => {
});
});
-// Final submit
-router.post('/new/submit', (req, res) => {
+// Final submit — IMMEDIATE dial (don't wait for the 5s worker tick).
+router.post('/new/submit', async (req, res) => {
const r = data.addCall(req.body || {});
if (!r.ok) {
const state = pickState(req.body);
@@ -148,7 +148,79 @@ router.post('/new/submit', (req, res) => {
errors: r.errors,
});
}
- res.redirect('/calls/' + r.call.id);
+ // Kick the dial in-process so the user lands on /live with the call already moving.
+ // Errors are non-fatal; the 5s worker tick will catch any that fail here.
+ try {
+ const twilio = require('../lib/twilio');
+ twilio.dialCall(r.call).catch(e => console.error('[immediate-dial]', e.message));
+ } catch (e) { console.error('[immediate-dial-spawn]', e.message); }
+ res.redirect('/calls/' + r.call.id + '/live');
+});
+
+// LIVE call page — popup UX with status polling + AI chat side-panel
+router.get('/calls/:id/live', (req, res) => {
+ const id = req.params.id;
+ if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
+ const c = data.getCall(id, false);
+ if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
+ res.render('public/call-live', {
+ title: c.business_name + ' — live call — Butlr',
+ meta_desc: 'Live call status with AI assistant.',
+ call: c,
+ });
+});
+
+// JSON status endpoint for live polling
+router.get('/api/calls/:id/status', (req, res) => {
+ const id = req.params.id;
+ if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
+ const c = data.getCall(id, false);
+ if (!c) return res.status(404).json({ ok: false });
+ res.json({
+ ok: true,
+ id: c.id,
+ status: c.status,
+ business_name: c.business_name,
+ business_phone: c.business_phone,
+ callback_phone: c.callback_phone,
+ goal: c.goal,
+ created_at: c.created_at,
+ updated_at: c.updated_at || c.created_at,
+ });
+});
+
+// AI chat endpoint — local Ollama (qwen3:14b per Steve's no-Anthropic standing rule)
+router.post('/api/calls/:id/chat', express.json(), async (req, res) => {
+ const id = req.params.id;
+ if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
+ const c = data.getCall(id, false);
+ if (!c) return res.status(404).json({ ok: false });
+ const userMsg = String((req.body && req.body.message) || '').slice(0, 1000).trim();
+ if (!userMsg) return res.status(400).json({ ok: false, error: 'empty message' });
+
+ const systemPrompt = `You are Butlr, an AI concierge whose job is to wait on hold for the user while their call to ${c.business_name} progresses. The user submitted this goal: "${c.goal}". The current call status is "${c.status}". Be brief (2-3 sentences max), helpful, conversational. If the user wants to tell you something to say to the rep when they pick up, write it down. If the user asks for an update, restate the current status plainly. Never pretend the call has connected when it hasn't. Never invent details about the call. You are NOT the rep — the rep hasn't picked up yet (unless status is "connected" or "done"). Stay in this persona.`;
+
+ try {
+ const oResp = await fetch('http://127.0.0.1:11434/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: 'qwen3:14b',
+ stream: false,
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: userMsg },
+ ],
+ options: { temperature: 0.4, num_predict: 200 },
+ }),
+ });
+ if (!oResp.ok) throw new Error('ollama returned HTTP ' + oResp.status);
+ const j = await oResp.json();
+ const reply = (j.message && j.message.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
+ res.json({ ok: true, reply });
+ } catch (e) {
+ res.json({ ok: true, reply: `(AI offline — Ollama error: ${e.message}). Your call to ${c.business_name} is currently ${c.status}; we'll text you when an agent picks up.` });
+ }
});
router.get('/calls', (req, res) => {
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 4897b5f..855cd25 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -23,6 +23,23 @@ const elevenlabs = require('../lib/elevenlabs');
const data = require('../lib/data');
const transcribe = require('../lib/transcribe');
+// IVR DTMF lookup — given a call's business phone, return the configured
+// digit sequence (delay → press) for navigating to a human. Pulled from
+// data/businesses.json's `ivr_dtmf` field per business.
+function ivrDtmfForPhone(phone) {
+ try {
+ const all = require('../data/businesses.json');
+ const normalize = s => String(s||'').replace(/\D/g,'');
+ const wanted = normalize(phone);
+ // Compare with leading-1 stripped both ways
+ const match = all.find(b => {
+ const bn = normalize(b.phone);
+ return bn === wanted || ('1'+bn) === wanted || bn === ('1'+wanted);
+ });
+ return match && Array.isArray(match.ivr_dtmf) ? match.ivr_dtmf : [];
+ } catch { return []; }
+}
+
const router = express.Router();
function publicUrl(req) {
@@ -33,8 +50,12 @@ function wsUrl(req) {
return pub.replace(/^http/, 'ws');
}
-// ── GET /twilio/twiml/:call_id ────────────────────────────────────────
-router.get('/twiml/:call_id', async (req, res) => {
+// ── /twilio/twiml/:call_id (GET for browser preview, POST for Twilio) ──
+// Twilio sends POST by default when fetching call instructions. Without this,
+// every live dial would 404 and Twilio would play its default "an application
+// error has occurred" message and hang up — which is exactly what happened
+// on the four 4-5s test calls on 2026-05-12 21:37 UTC. Accept both methods.
+async function twimlHandler(req, res) {
const callId = req.params.call_id;
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
@@ -59,7 +80,16 @@ router.get('/twiml/:call_id', async (req, res) => {
announceXml = `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
}
- // Full TwiML — stream + announce + pause + record + extended pause.
+ // ── IVR DTMF sequence ────────────────────────────────────────────────
+ // For known businesses (per data/businesses.json), play the configured
+ // DTMF tones with delays so we navigate the IVR menu automatically.
+ // Wells Fargo CC = "0, 0, 0" with realistic delays to reach a human.
+ const ivrSeq = ivrDtmfForPhone(call.business_phone);
+ const ivrTwiml = ivrSeq.map(step =>
+ `<Pause length="${Math.max(1, parseInt(step.delay_sec, 10) || 4)}"/><Play digits="${String(step.digit).replace(/[^0-9wW*#]/g,'')}"/>`
+ ).join('\n ');
+
+ // Full TwiML — stream + announce + pause + IVR-navigate + record + extended pause.
// recordingStatusCallback fires when the recording is ready for Whisper.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
@@ -68,6 +98,7 @@ router.get('/twiml/:call_id', async (req, res) => {
</Start>
${announceXml}
<Pause length="2"/>
+ ${ivrTwiml}
<Record
timeout="3600"
maxLength="3600"
@@ -80,7 +111,9 @@ router.get('/twiml/:call_id', async (req, res) => {
<Pause length="3600"/>
</Response>`;
res.type('text/xml').send(xml);
-});
+}
+router.get('/twiml/:call_id', twimlHandler);
+router.post('/twiml/:call_id', twimlHandler);
// ── POST /twilio/status/:call_id ──────────────────────────────────────
// Maps Twilio call lifecycle to Butlr internal status.
diff --git a/views/public/call-live.ejs b/views/public/call-live.ejs
new file mode 100644
index 0000000..e975a96
--- /dev/null
+++ b/views/public/call-live.ejs
@@ -0,0 +1,175 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="live-call-page">
+ <section class="section">
+ <div class="wrap">
+ <p class="muted small"><a href="/calls">← All calls</a></p>
+
+ <div class="live-grid">
+ <!-- LEFT: Live status card -->
+ <article class="live-status-card">
+ <header class="live-status-head">
+ <h1 id="live-business"><%= call.business_name %></h1>
+ <span class="status status-<%= call.status %>" id="live-status-pill"><%= call.status %></span>
+ </header>
+ <p class="muted small">
+ Calling <span id="live-phone"><%= call.business_phone %></span> · we'll ring you at <strong><%= call.callback_phone %></strong> when an agent picks up
+ </p>
+
+ <div class="live-timeline">
+ <div class="live-stage" data-stage="queued"><span class="live-stage-dot"></span> Queued</div>
+ <div class="live-stage" data-stage="dialing"><span class="live-stage-dot"></span> Dialing</div>
+ <div class="live-stage" data-stage="on_hold"><span class="live-stage-dot"></span> On hold</div>
+ <div class="live-stage" data-stage="connected"><span class="live-stage-dot"></span> Connected — bridging you</div>
+ <div class="live-stage" data-stage="done"><span class="live-stage-dot"></span> Done</div>
+ </div>
+
+ <div class="live-elapsed">
+ Elapsed: <span id="live-elapsed">0s</span>
+ </div>
+
+ <section class="live-goal-block">
+ <h3>Your goal</h3>
+ <p class="prose"><%= call.goal %></p>
+ </section>
+
+ <div class="live-actions">
+ <a class="btn ghost" href="/calls/<%= call.id %>">Switch to detail view</a>
+ <a class="btn ghost" href="/calls/<%= call.id %>?reveal=1">Reveal account info</a>
+ </div>
+ </article>
+
+ <!-- RIGHT: Butlr AI chat (qwen3:14b via Ollama, local) -->
+ <aside class="live-chat-card">
+ <header class="live-chat-head">
+ <span class="brand-mark">🎩</span>
+ <div>
+ <strong>Butlr Assistant</strong>
+ <span class="muted small">Chat while we wait on hold</span>
+ </div>
+ </header>
+ <div class="live-chat-messages" id="chat-messages">
+ <div class="chat-msg butlr">
+ <strong>Butlr</strong>
+ <p>Hi <%= call.callback_name || 'there' %>. I'm on the line with <%= call.business_name %> right now. Status: <strong><%= call.status %></strong>. While we wait, tell me anything else you want me to mention to the rep — or just ask me what's happening.</p>
+ </div>
+ </div>
+ <form id="chat-form" class="live-chat-form">
+ <textarea id="chat-input" rows="2" placeholder="Ask anything, or add a note for the rep…" maxlength="800" required></textarea>
+ <button type="submit" class="btn primary" id="chat-send">Send</button>
+ </form>
+ </aside>
+ </div>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
+<script>
+(function() {
+ const callId = <%- JSON.stringify(call.id) %>;
+ const createdAt = new Date(<%- JSON.stringify(call.created_at) %>).getTime();
+ const STAGE_ORDER = ['queued','dialing','on_hold','connected','done'];
+ const statusPill = document.getElementById('live-status-pill');
+ const stages = Array.from(document.querySelectorAll('.live-stage'));
+ const elapsedEl = document.getElementById('live-elapsed');
+
+ function fmtElapsed(ms) {
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return s + 's';
+ return Math.floor(s/60) + 'm ' + (s%60) + 's';
+ }
+ function updateStages(status) {
+ const idx = STAGE_ORDER.indexOf(status);
+ stages.forEach((el, i) => {
+ el.classList.toggle('done', i < idx);
+ el.classList.toggle('current', i === idx);
+ });
+ }
+ setInterval(() => { elapsedEl.textContent = fmtElapsed(Date.now() - createdAt); }, 1000);
+
+ let lastStatus = <%- JSON.stringify(call.status) %>;
+ updateStages(lastStatus);
+
+ async function poll() {
+ try {
+ const r = await fetch('/api/calls/' + callId + '/status', { cache: 'no-store' });
+ const j = await r.json();
+ if (!j.ok) return;
+ if (j.status !== lastStatus) {
+ lastStatus = j.status;
+ statusPill.textContent = j.status;
+ statusPill.className = 'status status-' + j.status;
+ updateStages(j.status);
+ // System message in chat
+ const msgEl = document.createElement('div');
+ msgEl.className = 'chat-msg system';
+ msgEl.innerHTML = '<em>Status changed → <strong>' + j.status.replace(/_/g,' ') + '</strong></em>';
+ document.getElementById('chat-messages').appendChild(msgEl);
+ msgEl.scrollIntoView({behavior:'smooth'});
+ // If connected, alert + bigger visual
+ if (j.status === 'connected') {
+ document.title = '📞 PICK UP — ' + j.business_name;
+ try {
+ // soft beep via Web Audio (no asset needed)
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.frequency.value = 880; gain.gain.value = .25;
+ osc.start(); osc.stop(ctx.currentTime + 0.4);
+ } catch (_) {}
+ }
+ }
+ } catch (e) {}
+ }
+ setInterval(poll, 2000);
+ poll(); // immediate
+
+ // ── AI Chat ──────────────────────────────────────────────────────
+ const form = document.getElementById('chat-form');
+ const input = document.getElementById('chat-input');
+ const sendBtn = document.getElementById('chat-send');
+ const msgs = document.getElementById('chat-messages');
+
+ function appendMsg(who, text) {
+ const el = document.createElement('div');
+ el.className = 'chat-msg ' + who;
+ const strong = document.createElement('strong');
+ strong.textContent = who === 'you' ? 'You' : 'Butlr';
+ const p = document.createElement('p');
+ p.textContent = text;
+ el.appendChild(strong); el.appendChild(p);
+ msgs.appendChild(el);
+ el.scrollIntoView({behavior:'smooth'});
+ }
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (!text) return;
+ appendMsg('you', text);
+ input.value = '';
+ sendBtn.disabled = true;
+ sendBtn.textContent = 'Thinking…';
+ try {
+ const r = await fetch('/api/calls/' + callId + '/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: text }),
+ });
+ const j = await r.json();
+ appendMsg('butlr', j.reply || '(no reply)');
+ } catch (e) {
+ appendMsg('butlr', '(AI offline)');
+ } finally {
+ sendBtn.disabled = false;
+ sendBtn.textContent = 'Send';
+ input.focus();
+ }
+ });
+
+ input.focus();
+})();
+</script>
← f463de5 API-Key auth preferred over Auth Token for Twilio
·
back to Butlr
·
wire MacEnthusiasts IVR DTMF (press 2 for sales) ffd47bd →