[object Object]

← back to Butlr

twilio: kill <Redirect> + inline-Gather + inbound_track + AudioCtx guard

cd10f95b81990421f405ece3ddd163c736775835 · 2026-05-12 17:44:57 -0700 · SteveStudio2

Three fixes that together unblock live audio on Steve's calls:

1. routes/twilio-webhooks.js — DROP <Redirect> after Gather. Redirect ends
   the TwiML execution context and tears down <Start><Stream/>. Switch to
   self-loop inline Gather: /gather POST returns a new <Say> + inline
   <Gather> in the same response, so the stream persists across all turns
   of the conversation. Also add track="inbound_track" to <Stream> — the
   default both_tracks mixes Butlr's outbound TTS into the listen feed.
   Plus action-first system prompt with worked examples for press/wait/
   escalate/hangup so qwen3 doesn't deflect with "One moment please".

2. views/public/call-live.ejs — beep on status=connected now guards on
   window.__butlrAudioCtx (only fires if user already clicked "Listen live"
   to satisfy Chrome's autoplay gesture policy). Listen-live block exposes
   audioCtx as window.__butlrAudioCtx so the guard works.

3. server.js — add uncaughtException + unhandledRejection + signal
   handlers. pm2 was restarting butlr ~5×/10min on 2026-05-12 with clean
   SIGINT exits and no stack traces; explicit handlers surface the
   cause on next crash.

Also: views/public/listen.ejs split into live/archive sections so the
post-call MP3 player auto-shows once call.status hits done/failed.

Files touched

Diff

commit cd10f95b81990421f405ece3ddd163c736775835
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 17:44:57 2026 -0700

    twilio: kill <Redirect> + inline-Gather + inbound_track + AudioCtx guard
    
    Three fixes that together unblock live audio on Steve's calls:
    
    1. routes/twilio-webhooks.js — DROP <Redirect> after Gather. Redirect ends
       the TwiML execution context and tears down <Start><Stream/>. Switch to
       self-loop inline Gather: /gather POST returns a new <Say> + inline
       <Gather> in the same response, so the stream persists across all turns
       of the conversation. Also add track="inbound_track" to <Stream> — the
       default both_tracks mixes Butlr's outbound TTS into the listen feed.
       Plus action-first system prompt with worked examples for press/wait/
       escalate/hangup so qwen3 doesn't deflect with "One moment please".
    
    2. views/public/call-live.ejs — beep on status=connected now guards on
       window.__butlrAudioCtx (only fires if user already clicked "Listen live"
       to satisfy Chrome's autoplay gesture policy). Listen-live block exposes
       audioCtx as window.__butlrAudioCtx so the guard works.
    
    3. server.js — add uncaughtException + unhandledRejection + signal
       handlers. pm2 was restarting butlr ~5×/10min on 2026-05-12 with clean
       SIGINT exits and no stack traces; explicit handlers surface the
       cause on next crash.
    
    Also: views/public/listen.ejs split into live/archive sections so the
    post-call MP3 player auto-shows once call.status hits done/failed.
---
 routes/twilio-webhooks.js  | 92 ++++++++++++++++++++++++++++++++++++++--------
 server.js                  | 14 +++++++
 views/public/call-live.ejs | 25 ++++++++-----
 views/public/listen.ejs    | 52 ++++++++++++++++++++++++--
 4 files changed, 154 insertions(+), 29 deletions(-)

diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index a03ebfa..2632441 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -59,6 +59,24 @@ function turnsFor(callId) {
   return CONV.get(callId);
 }
 
+// ── Stream lifecycle (corrected 2026-05-13) ─────────────────────────
+// Per Twilio Media Streams docs: <Start><Stream/></Start> persists for
+// the lifetime of the TwiML execution context. A <Redirect> ENDS that
+// context — Twilio fetches a new TwiML document and the prior stream is
+// torn down. `name=` attribute dedupes within a single document only;
+// it does NOT survive Redirect boundaries.
+//
+// Correct fix (Option B from the debugger subagent review): the initial
+// /twiml/:call_id renders <Start><Stream> ONCE; the /gather handler
+// returns a self-loop inline <Gather> (no <Redirect>) so the response
+// chain stays in one execution context for the whole call. The Stream
+// stays alive until call hangs up.
+function streamStartXml(callId, wsBase) {
+  // track="inbound_track" → business voice only (default both_tracks would
+  // mix Butlr's outbound TTS into the listener feed, causing echo).
+  return `<Start><Stream url="${wsBase}/stream/${callId}" track="inbound_track"/></Start>`;
+}
+
 // ── /twilio/twiml/:call_id  (initial TwiML when Twilio dials a business)
 async function twimlHandler(req, res) {
   const callId = req.params.call_id;
@@ -95,9 +113,7 @@ async function twimlHandler(req, res) {
   // Steve's phone never rings — Butlr conducts the entire call autonomously.
   const xml = `<?xml version="1.0" encoding="UTF-8"?>
 <Response>
-  <Start>
-    <Stream url="${ws}/stream/${callId}"/>
-  </Start>
+  ${streamStartXml(callId, ws)}
   ${announceXml}
   <Pause length="1"/>
   <Gather input="speech dtmf"
@@ -110,8 +126,9 @@ async function twimlHandler(req, res) {
           hints="press 0,representative,agent,operator,customer service,billing,new account,credit limit,for a representative">
     <Pause length="20"/>
   </Gather>
-  <!-- If Gather completes without input, loop back to itself -->
-  <Redirect>${pub}/twilio/twiml/${callId}</Redirect>
+  <!-- actionOnEmptyResult=true means the Gather POSTs to /gather even on
+       empty input; /gather self-loops with inline Gather, so we never
+       return here. NO <Redirect> — that would tear down <Stream>. -->
 </Response>`;
   res.type('text/xml').send(xml);
 }
@@ -142,15 +159,52 @@ router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (r
   console.log(`[ai-agent] call=${callId} heard: "${incoming.slice(0,80)}"  turn=${turns.length}`);
 
   // Build the LLM prompt
-  const systemPrompt = `You are Butlr, an AI phone agent calling ${call.business_name} on behalf of ${call.callback_name || 'the customer'}. The customer's goal is: "${call.goal}". The customer's account info (use only if directly asked):
-- Account number ending in: ${(call.account_number||'').slice(-4) || '????'}
-- Last 4 SSN: ${call.last4_ssn ? '[provided, last 4: ' + call.last4_ssn + ']' : '[not provided]'}
-- Billing ZIP: ${call.billing_zip || '[not provided]'}
-- DOB: ${call.date_of_birth || '[not provided]'}
+  // Action-first design: model must emit a JSON {action:...} object whenever
+  // the input contains anything that resembles an IVR menu, hold music,
+  // verification ask, or goal-complete signal. Prose is reserved for when
+  // a human rep is actually on the line. The 2026-05-12 MacEnthusiasts test
+  // call exposed that qwen2.5:7b would deflect with "One moment please…"
+  // instead of pressing a digit; this prompt corrects that with worked
+  // examples for every action type.
+  const systemPrompt = `You are Butlr, an automated phone assistant calling ${call.business_name} for ${call.callback_name || 'the customer'}.
+
+GOAL: ${call.goal}
+
+═══ DECISION TABLE — pick ONE action per turn ═══
+
+You will hear text from the other end of the line. Identify what KIND of input it is, then emit the matching response. JSON actions MUST be on their own line with no other text. Examples below — match the PATTERN, substitute the right digit/reason:
+
+1. IVR MENU heard (any list of "press N for X" / "for X press N" / "say or press N") → emit JSON, no prose:
+   Input:  "For sales, press 2. For service, press 3. For store hours and address, press 5."
+   Output: {"action":"press","digit":"5"}
 
-You are speaking on a phone call. Be brief — 1-2 short sentences max per turn. Sound natural and polite. If you hear an IVR menu listing options, respond with the digit to press as JSON like: {"action":"press","digit":"0"}. If you hear hold music or "please continue holding", respond {"action":"wait"}. If you hear a human representative greeting you, introduce yourself and state the goal. If the rep needs verification only the customer can provide (e.g. verbal password, full SSN, DOB), respond {"action":"escalate","reason":"<why>"}. If the goal is achieved or call should end, respond {"action":"hangup","reason":"<why>"}. Otherwise, just respond with a normal spoken sentence — do NOT use JSON for normal speech.
+   Input:  "Para español, oprima 9. For English, press 1."
+   Output: {"action":"press","digit":"1"}
 
-You will hear what ${call.business_name} just said. Respond appropriately. Keep responses SHORT (under 25 words).`;
+2. HOLD MUSIC or "please continue holding" / "your call is important" / silence → wait:
+   Input:  "Please continue to hold. Your call is important to us."
+   Output: {"action":"wait"}
+
+3. VERIFICATION request that ONLY the customer can answer (full SSN, verbal password, full DOB) → escalate:
+   Input:  "I'll need your full social security number and date of birth to continue."
+   Output: {"action":"escalate","reason":"rep asked for full SSN + DOB"}
+
+4. GOAL ACHIEVED or call should end (rep gave the info, transferred, said goodbye) → hangup:
+   Input:  "Our address is 1234 Pico Boulevard and we're open ten to six."
+   Output: {"action":"hangup","reason":"got address and hours"}
+
+5. HUMAN REP greets you (only after pressing through IVR) → speak ONE short sentence introducing yourself + goal, no JSON:
+   Input:  "Thanks for calling MacEnthusiasts, this is Sarah, how can I help?"
+   Output: Hi Sarah, I'm calling for ${call.callback_name || 'a customer'} — quick question about ${call.goal.slice(0, 40)}…
+
+═══ HARD RULES ═══
+- NEVER reply with "One moment please" or "I'm checking on that" — those are useless deflections.
+- If unsure which digit, press 0 (usually goes to operator): {"action":"press","digit":"0"}
+- If the IVR repeats itself, you MUST press a digit on the SECOND repeat, not wait again.
+- Keep ANY prose reply under 20 words.
+- Account info if asked: last4 ${(call.account_number||'').slice(-4) || 'n/a'}, SSN ${call.last4_ssn || 'n/a'}, ZIP ${call.billing_zip || 'n/a'}, DOB ${call.date_of_birth || 'n/a'}.
+
+You will now hear what ${call.business_name} just said. Decide ACTION first, then format.`;
 
   // ── LLM call with hard 4s timeout ────────────────────────────────────
   // Model: hermes3:8b — warm response ~0.3s on M2 Max vs qwen3:14b ~6.9s.
@@ -215,7 +269,11 @@ You will hear what ${call.business_name} just said. Respond appropriately. Keep
     actionTwiml = await playOrSay(cleanSpeech, pub);
   }
 
-  // Continue the conversation with another Gather (unless we hung up)
+  // Continue the conversation with another inline Gather (unless we hung up).
+  // CRITICAL: do NOT use <Redirect> here — Redirect ends the TwiML execution
+  // context and kills the <Stream>. Inline Gather loops within the same
+  // execution context. Twilio loops Gather → /gather → next response → Gather
+  // and the stream persists across all of it.
   const continueTwiml = (aiAction && (aiAction.action === 'hangup' || aiAction.action === 'escalate'))
     ? ''
     : `<Gather input="speech dtmf"
@@ -226,8 +284,7 @@ You will hear what ${call.business_name} just said. Respond appropriately. Keep
               actionOnEmptyResult="true"
               speechModel="phone_call">
         <Pause length="20"/>
-      </Gather>
-      <Redirect>${pub}/twilio/twiml/${callId}</Redirect>`;
+      </Gather>`;
 
   res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
 <Response>
@@ -248,6 +305,11 @@ router.post('/status/:call_id', (req, res) => {
   else if (cs === 'completed')                internal = 'done';
   else if (['busy','failed','no-answer','canceled'].includes(cs)) internal = 'failed';
   if (internal) data.updateStatus(callId, internal);
+  // Conversation cleanup on terminal status. (STREAMED Set was removed
+  // when we switched to the no-Redirect inline-Gather pattern.)
+  if (cs === 'completed' || ['busy','failed','no-answer','canceled'].includes(cs)) {
+    CONV.delete(callId);
+  }
   res.type('text/xml').send('<Response/>');
 });
 
diff --git a/server.js b/server.js
index 4af5051..eb12894 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,20 @@ const users = require('./lib/users');
 // keep working through the migration. Idempotent — safe to run on every boot.
 try { users.seedLegacyAdmin(); } catch (e) { console.error('[users] seedLegacyAdmin failed:', e.message); }
 
+// ── Crash diagnostics ─────────────────────────────────────────────────
+// pm2 restarted butlr ~5 times in ~10 min during the 2026-05-12 session,
+// all with clean SIGINT (signal 2) exits — no JS stack traces in the
+// error log. Add explicit handlers so future crashes show what threw.
+// Keep the process alive on caught exceptions; only SIGTERM/SIGINT exit.
+process.on('uncaughtException', (err) => {
+  console.error('[CRASH] uncaughtException at', new Date().toISOString(), err && err.stack || err);
+});
+process.on('unhandledRejection', (reason, p) => {
+  console.error('[CRASH] unhandledRejection at', new Date().toISOString(), reason && reason.stack || reason);
+});
+process.on('SIGINT',  () => { console.error('[SIGNAL] SIGINT received at',  new Date().toISOString()); process.exit(0); });
+process.on('SIGTERM', () => { console.error('[SIGNAL] SIGTERM received at', new Date().toISOString()); process.exit(0); });
+
 const app = express();
 const PORT = parseInt(process.env.PORT || '9932', 10);
 const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
diff --git a/views/public/call-live.ejs b/views/public/call-live.ejs
index 0c443dd..d35cb12 100644
--- a/views/public/call-live.ejs
+++ b/views/public/call-live.ejs
@@ -141,15 +141,19 @@
         // 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 (_) {}
+          // Only beep if the user has already unlocked audio via "🔊 Listen live".
+          // Creating a fresh AudioContext from a poll callback (no gesture) gets
+          // silently auto-suspended by Chrome — guard so we don't burn cycles.
+          if (window.__butlrAudioCtx) {
+            try {
+              const ctx = window.__butlrAudioCtx;
+              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) {}
@@ -224,6 +228,7 @@
       listenBtn.classList.remove('pulse');
 
       audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
+      window.__butlrAudioCtx = audioCtx;  // unlocks the "connected" beep below
       nextPlayTime = audioCtx.currentTime + 0.1;
 
       const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -255,7 +260,7 @@
     function disconnectAudio() {
       listening = false;
       if (ws) { ws.close(); ws = null; }
-      if (audioCtx) { audioCtx.close(); audioCtx = null; }
+      if (audioCtx) { audioCtx.close(); audioCtx = null; window.__butlrAudioCtx = null; }
       listenBtn.textContent = '🔊 Listen live';
       statusBar.style.display = 'none';
       frameCount = 0;
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index a5b86b0..58c16a7 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -10,11 +10,32 @@
   </p>
 
   <div class="card" style="padding: 20px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel);">
-    <div style="display:flex; align-items:center; gap: 16px; margin-bottom: 12px;">
-      <button id="connect-btn" style="padding: 10px 18px; font-size: 14px;">▶ Connect &amp; listen</button>
-      <span id="ws-state" class="muted" style="font-size: 13px;">disconnected</span>
-      <span id="frame-count" class="muted" style="font-size: 12px; margin-left: auto;">0 frames</span>
+
+    <!-- Live WebSocket listen-in (works while call is in-progress) -->
+    <div id="live-section" style="<%= ['done','failed'].includes(call.status) ? 'display:none' : '' %>">
+      <div style="display:flex; align-items:center; gap: 16px; margin-bottom: 12px;">
+        <button id="connect-btn" style="padding: 10px 18px; font-size: 14px;">▶ Connect &amp; listen live</button>
+        <span id="ws-state" class="muted" style="font-size: 13px;">disconnected</span>
+        <span id="frame-count" class="muted" style="font-size: 12px; margin-left: auto;">0 frames</span>
+      </div>
+    </div>
+
+    <!-- Archived MP3 player (shows once call is done) -->
+    <div id="archive-section" style="<%= ['done','failed'].includes(call.status) ? '' : 'display:none' %>; margin-bottom: 12px;">
+      <div style="font-size:13px; color:var(--ink); margin-bottom:6px;">
+        <strong>📼 Call archive</strong> · <span class="muted">post-call recording</span>
+      </div>
+      <audio id="archive-audio"
+             controls preload="metadata"
+             src="/api/calls/<%= call.id %>/recording"
+             style="width:100%; height:40px;">
+        Your browser does not support the audio element.
+      </audio>
+      <div id="archive-warning" class="muted" style="display:none; font-size:12px; margin-top:6px; color:#d33;">
+        Recording not yet on disk (or call hasn't been archived). Refresh in a few seconds.
+      </div>
     </div>
+
     <div style="background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 12px; line-height: 1.6; color: var(--ink-dim);">
       <strong style="color: var(--ink);">Privacy posture:</strong>
       The called party hears a two-party-consent announcement before audio is bridged.
@@ -95,6 +116,28 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
   });
 
   // Poll call status every 5s so the pill updates as the call moves through stages.
+  // When status flips to done/failed, hide live WS section and reveal the
+  // archived-MP3 player so user can immediately play back the call.
+  let _archiveRevealed = false;
+  function revealArchive() {
+    if (_archiveRevealed) return;
+    _archiveRevealed = true;
+    const live = document.getElementById('live-section');
+    const arch = document.getElementById('archive-section');
+    if (live) live.style.display = 'none';
+    if (arch) {
+      arch.style.display = '';
+      const audio = document.getElementById('archive-audio');
+      const warn  = document.getElementById('archive-warning');
+      if (audio) {
+        // Force reload in case src was preloaded as 404 during in-progress
+        audio.src = '/api/calls/' + callId + '/recording?ts=' + Date.now();
+        audio.load();
+        audio.addEventListener('error', () => { if (warn) warn.style.display = ''; }, { once: true });
+      }
+    }
+    if (ws) disconnect(); // tear down WS, no longer useful
+  }
   setInterval(async () => {
     try {
       const r = await fetch('/api/calls/' + callId + '/status', { cache: 'no-store' });
@@ -102,6 +145,7 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
       if (j && j.ok) {
         const pill = document.getElementById('status-pill');
         if (pill) pill.textContent = j.status;
+        if (['done','failed','completed'].includes(j.status)) revealArchive();
       }
     } catch {}
   }, 5000);

← da345d1 call-live: integrate listen-live audio inline so call audio  ·  back to Butlr  ·  calls: inline MP3 player per row + has-recording probe + LLM ead4134 →