[object Object]

← back to Butlr

call-live: integrate listen-live audio inline so call audio auto-prompts when status hits dialing

da345d1e4d032652bdd3b230717e5be32fe2b73a · 2026-05-12 17:40:14 -0700 · SteveStudio2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit da345d1e4d032652bdd3b230717e5be32fe2b73a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 17:40:14 2026 -0700

    call-live: integrate listen-live audio inline so call audio auto-prompts when status hits dialing
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 views/public/call-live.ejs | 107 ++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 106 insertions(+), 1 deletion(-)

diff --git a/views/public/call-live.ejs b/views/public/call-live.ejs
index 84f4f11..0c443dd 100644
--- a/views/public/call-live.ejs
+++ b/views/public/call-live.ejs
@@ -34,13 +34,26 @@
             <p class="prose"><%= call.goal %></p>
           </section>
 
+          <!-- LISTEN LIVE button — gesture-policy unlock for Web Audio -->
+          <div id="listen-live-wrap" class="live-actions" style="margin-bottom:.75rem">
+            <button id="listen-live-btn" class="btn ghost" style="font-size:1.05rem;padding:1rem 1.4rem;border:2px solid var(--accent,#1a56db);color:var(--accent,#1a56db)">
+              🔊 Listen live
+            </button>
+          </div>
+          <div id="listen-banner" style="display:none;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;padding:.6rem 1rem;margin-bottom:.75rem;font-size:.92rem;font-weight:600;color:#92400e">
+            Call is dialing — click <strong>🔊 Listen live</strong> to hear it live
+          </div>
+          <div id="listen-status-bar" style="display:none;font-size:.78rem;color:var(--ink-dim,#6b7280);margin-bottom:.75rem;font-family:monospace">
+            <span id="listen-ws-state">disconnected</span> &nbsp;·&nbsp; <span id="listen-frame-count">0 frames</span>
+          </div>
+
           <div class="live-actions">
             <button id="bridge-now" class="btn primary" style="font-size:1.05rem;padding:1rem 1.4rem">
               📞 Bridge me NOW — connect my phone
             </button>
           </div>
           <p class="muted small" style="margin-top:.5rem">
-            Watch / listen to the call audio via the stream. The moment you hear a real human, click "Bridge me now" — your phone rings in 2 seconds and you're connected. Use this when AMD didn't auto-detect the human.
+            Click <strong>🔊 Listen live</strong> first to open the audio stream. The moment you hear a real human, click "Bridge me now" — your phone rings in 2 seconds and you're connected. Use this when AMD didn't auto-detect the human.
           </p>
           <div class="live-actions" style="margin-top:1rem">
             <a class="btn ghost" href="/calls/<%= call.id %>">Detail view</a>
@@ -74,6 +87,15 @@
 </main>
 
 <%- include('../partials/footer') %>
+<style>
+@keyframes listen-pulse {
+  0%,100% { box-shadow: 0 0 0 0 rgba(245,158,11,.6); border-color: #f59e0b; color: #92400e; }
+  50%      { box-shadow: 0 0 0 8px rgba(245,158,11,0); border-color: #d97706; color: #78350f; }
+}
+#listen-live-btn.pulse {
+  animation: listen-pulse 1.2s ease-in-out infinite;
+}
+</style>
 <script>
 (function() {
   const callId = <%- JSON.stringify(call.id) %>;
@@ -180,6 +202,89 @@
 
   input.focus();
 
+  // ── Listen-live audio (inline, gesture-unlocked) ──────────────────
+  (function() {
+    const SAMPLE_RATE = 8000;
+    const listenBtn   = document.getElementById('listen-live-btn');
+    const banner      = document.getElementById('listen-banner');
+    const statusBar   = document.getElementById('listen-status-bar');
+    const wsStateEl   = document.getElementById('listen-ws-state');
+    const frameEl     = document.getElementById('listen-frame-count');
+    let audioCtx   = null;
+    let ws         = null;
+    let frameCount = 0;
+    let nextPlayTime = 0;
+    let listening  = false;
+
+    function connect() {
+      listening = true;
+      banner.style.display = 'none';
+      statusBar.style.display = 'block';
+      listenBtn.textContent = '🔇 Stop listening';
+      listenBtn.classList.remove('pulse');
+
+      audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
+      nextPlayTime = audioCtx.currentTime + 0.1;
+
+      const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
+      ws = new WebSocket(`${scheme}//${location.host}/listen-ws/${callId}`);
+      ws.binaryType = 'arraybuffer';
+
+      ws.onopen  = () => { wsStateEl.textContent = 'connected · waiting for audio'; };
+      ws.onclose = () => { wsStateEl.textContent = 'disconnected'; if (listening) { listening = false; listenBtn.textContent = '🔊 Listen live'; } };
+      ws.onerror = () => { wsStateEl.textContent = 'error'; };
+      ws.onmessage = (ev) => {
+        // Server sends PCM16 LE @ 8 kHz mono (same as listen.ejs)
+        const pcm16   = new Int16Array(ev.data);
+        const float32 = new Float32Array(pcm16.length);
+        for (let i = 0; i < pcm16.length; i++) float32[i] = pcm16[i] / 32768;
+        const buf = audioCtx.createBuffer(1, float32.length, SAMPLE_RATE);
+        buf.copyToChannel(float32, 0);
+        const src = audioCtx.createBufferSource();
+        src.buffer = buf;
+        src.connect(audioCtx.destination);
+        const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
+        src.start(startAt);
+        nextPlayTime = startAt + buf.duration;
+        frameCount++;
+        if (frameCount % 25 === 0) frameEl.textContent = frameCount + ' frames';
+        if (wsStateEl.textContent !== 'streaming') wsStateEl.textContent = 'streaming';
+      };
+    }
+
+    function disconnectAudio() {
+      listening = false;
+      if (ws) { ws.close(); ws = null; }
+      if (audioCtx) { audioCtx.close(); audioCtx = null; }
+      listenBtn.textContent = '🔊 Listen live';
+      statusBar.style.display = 'none';
+      frameCount = 0;
+    }
+
+    listenBtn.addEventListener('click', () => {
+      if (listening) disconnectAudio(); else connect();
+    });
+
+    // When status hits dialing/on_hold, pulse the button and show the banner
+    // The existing poll() updates `lastStatus` — we hook in via a MutationObserver
+    // on the status pill which is already updated by poll().
+    const pill = document.getElementById('live-status-pill');
+    if (pill) {
+      const obs = new MutationObserver(() => {
+        const s = pill.textContent.trim();
+        if ((s === 'dialing' || s === 'on_hold') && !listening) {
+          listenBtn.classList.add('pulse');
+          banner.style.display = 'block';
+        }
+        // Auto-close WSS when call ends
+        if ((s === 'done' || s === 'failed') && listening) {
+          disconnectAudio();
+        }
+      });
+      obs.observe(pill, { childList: true, characterData: true, subtree: true });
+    }
+  })();
+
   // ── Manual bridge button ──────────────────────────────────────────
   const bridgeBtn = document.getElementById('bridge-now');
   if (bridgeBtn) {

← 4a77606 audio: finalize-recordings.js + diagnostics that found Twili  ·  back to Butlr  ·  twilio: kill <Redirect> + inline-Gather + inbound_track + Au cd10f95 →