← 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
M views/public/call-live.ejs
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> · <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 →