← back to Butlr
views/public/listen.ejs
349 lines
<%- include('../partials/head', { title, meta_desc }) %>
<%- include('../partials/header') %>
<main class="container" style="max-width: 880px; padding: 24px;">
<h1 style="margin: 0 0 6px;">Listen in — <%= call.business_name %></h1>
<p class="muted" style="margin: 0 0 18px;">
Call <code><%= call.id %></code> · status <strong id="status-pill"><%= call.status %></strong>
· <a href="/calls/<%= call.id %>">back to call detail</a>
</p>
<div class="card" style="padding: 20px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel);">
<!-- 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 & 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.
The full call is also recorded server-side via Twilio; a Whisper transcript is generated when the call ends.
Your listen-in here is read-only — you cannot speak into this stream.
</div>
</div>
<!-- Two-column layout: live transcript LEFT, takeover panel RIGHT -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-top:18px;">
<section style="padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);">
<header style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
<strong style="font-size:15px;">🎩 Live AI-agent transcript</strong>
<span id="transcript-count" class="muted" style="font-size:12px; margin-left:auto;">— turns</span>
</header>
<div id="transcript-feed" style="max-height: 480px; overflow-y: auto; font-size: 14px; line-height: 1.55;">
<p class="muted" style="font-style: italic;">Waiting for AI-agent turns…</p>
</div>
</section>
<section style="padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);">
<header style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
<strong style="font-size:15px;">🎙️ Take over — speak / hang up</strong>
<span class="muted" style="font-size:12px; margin-left:auto;">applies on next AI turn</span>
</header>
<textarea id="speak-text" rows="4" placeholder="Type what to say in your cloned voice (lands in ~3-20s on next gather)…" style="width:100%; box-sizing:border-box; padding:10px; border:1px solid var(--line); border-radius:6px; font-size:14px; font-family:inherit; resize:vertical;"></textarea>
<div style="display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap;">
<button id="ptt-btn" style="padding:10px 16px; font-size:14px; flex:1; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; cursor:pointer; border-radius:6px; font-weight:600;">🎙️ Push to talk</button>
<button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">✉️ Send typed text</button>
<button id="pickup-now" style="padding:10px 16px; font-size:14px; background:#dcfce7; border:1px solid #16a34a; color:#14532d; cursor:pointer; border-radius:6px; font-weight:600;">📞 Pick up</button>
<button id="hangup-now" style="padding:10px 16px; font-size:14px; background:#fee2e2; border:1px solid #d33; color:#900; cursor:pointer; border-radius:6px;">📴 Hang up</button>
</div>
<div id="takeover-status" class="muted" style="font-size:12px; margin-top:8px; min-height:16px;"></div>
<details style="margin-top:10px;">
<summary style="cursor:pointer; color:var(--ink-dim); font-size:12px;">quick phrases</summary>
<div style="display:flex; flex-wrap:wrap; gap:6px; margin-top:8px;">
<button class="quick-phrase" data-text="Hi, this is Steve Abrams calling on behalf of myself. Quick question please." style="padding:6px 10px; font-size:12px;">👋 Intro</button>
<button class="quick-phrase" data-text="Could you transfer me to a representative please?" style="padding:6px 10px; font-size:12px;">🔁 Transfer me</button>
<button class="quick-phrase" data-text="Could you repeat that please?" style="padding:6px 10px; font-size:12px;">🔂 Repeat</button>
<button class="quick-phrase" data-text="Thank you for your help. Have a great day. Goodbye." style="padding:6px 10px; font-size:12px;">👋 Polite bye</button>
</div>
</details>
</section>
</div>
<details style="margin-top: 16px;">
<summary style="cursor: pointer; color: var(--ink-dim); font-size: 13px;">technical notes</summary>
<pre style="background: var(--panel); padding: 12px; border-radius: 6px; font-size: 11px; line-height: 1.5; overflow-x: auto;">audio: µ-law 8 kHz mono → PCM16 LE on the server → Web Audio API playback
ws lane: /listen-ws/<%= call.id %>
twilio stream: /stream/<%= call.id %>
recording: data/recordings/<%= call.id %>.mp3 (post-call)
transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
</details>
</main>
<script>
(function() {
const callId = <%- JSON.stringify(call.id) %>;
// Twilio Media Streams = 8 kHz mulaw → decoded to PCM16 at 8 kHz.
// Vapi listen WS = raw PCM 16-bit at 16 kHz (default Vapi format).
// We pick the right AudioContext sample rate per-call so audio doesn't sound chipmunked or sludgey.
const SAMPLE_RATE = <%- call.vapi_call_id ? '16000' : '8000' %>;
const btn = document.getElementById('connect-btn');
const stateEl = document.getElementById('ws-state');
const countEl = document.getElementById('frame-count');
let ws = null;
let audioCtx = null;
let frameCount = 0;
let nextPlayTime = 0;
function connect() {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${scheme}//${location.host}/listen-ws/${callId}`;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
nextPlayTime = audioCtx.currentTime + 0.1;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => { stateEl.textContent = 'connected · waiting for audio'; btn.textContent = '■ Disconnect'; };
ws.onclose = () => { stateEl.textContent = 'disconnected'; btn.textContent = '▶ Connect & listen live'; };
ws.onerror = () => { stateEl.textContent = 'error'; };
ws.onmessage = (ev) => {
// PCM16 LE @ 8kHz mono
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) countEl.textContent = frameCount + ' frames';
};
}
function disconnect() {
if (ws) { ws.close(); ws = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
}
btn.addEventListener('click', () => {
if (ws) disconnect(); else connect();
});
// Auto-connect on page load — Steve asked not to be forced to click.
// Browsers require a user gesture for AudioContext; we initialize anyway
// and resume on first user interaction if blocked.
setTimeout(() => {
if (!ws) {
try { connect(); } catch (e) { console.error('auto-connect failed', e); }
// Resume AudioContext on any user click (browser autoplay policy).
const resumer = () => {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
document.removeEventListener('click', resumer);
};
document.addEventListener('click', resumer);
}
}, 200);
// 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' });
const j = await r.json();
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);
// ── Live takeover (speak / hangup) ────────────────────────────────
const takeoverStatus = document.getElementById('takeover-status');
function setTakeoverStatus(text, isErr) {
takeoverStatus.textContent = text || '';
takeoverStatus.style.color = isErr ? '#d33' : 'var(--ink-dim)';
if (text) setTimeout(() => { if (takeoverStatus.textContent === text) takeoverStatus.textContent = ''; }, 8000);
}
document.getElementById('speak-send').addEventListener('click', async () => {
const ta = document.getElementById('speak-text');
const text = ta.value.trim();
if (!text) { setTakeoverStatus('Type something first.', true); return; }
setTakeoverStatus('Sending to Butlr…');
try {
const r = await fetch('/api/calls/' + callId + '/say', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ text })
});
const j = await r.json();
if (j.ok) { setTakeoverStatus('✅ queued — will play on the next AI turn'); ta.value = ''; }
else setTakeoverStatus('❌ ' + (j.error || 'failed'), true);
} catch (e) { setTakeoverStatus('❌ network error: ' + e.message, true); }
});
document.getElementById('pickup-now').addEventListener('click', async () => {
if (!confirm('Pick up: your phone will ring shortly. When you answer, you bridge in to the live call and the AI drops out. Continue?')) return;
takeoverStatus.textContent = 'Bridging your phone in...';
try {
const r = await fetch('/api/calls/<%= call.id %>/pickup', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
const j = await r.json();
if (j.ok) { takeoverStatus.textContent = '\u2713 Bridging to ' + (j.transferring_to || 'your phone') + ' — answer when it rings.'; takeoverStatus.style.color = '#16a34a'; }
else { takeoverStatus.textContent = '\u2717 Pickup failed: ' + (j.error||'unknown'); takeoverStatus.style.color = '#d33'; }
} catch (e) { takeoverStatus.textContent = '\u2717 Pickup error: ' + e.message; takeoverStatus.style.color = '#d33'; }
});
document.getElementById('hangup-now').addEventListener('click', async () => {
if (!confirm('Hang up the call now?')) return;
setTakeoverStatus('Hanging up…');
try {
const r = await fetch('/api/calls/' + callId + '/hangup', { method:'POST' });
const j = await r.json();
setTakeoverStatus(j.ok ? '📴 hangup queued' : ('❌ ' + (j.error||'failed')), !j.ok);
} catch (e) { setTakeoverStatus('❌ network error: ' + e.message, true); }
});
document.querySelectorAll('.quick-phrase').forEach(btn => {
btn.addEventListener('click', () => {
const ta = document.getElementById('speak-text');
ta.value = btn.getAttribute('data-text') || '';
ta.focus();
});
});
// ── Push-to-talk via Web Speech API ───────────────────────────────
// Click to start mic recording + live transcription. Click again to stop;
// the transcript auto-submits to /say, which EL-synths in Steve's cloned
// voice and plays on the call. Skips the typing step entirely.
const pttBtn = document.getElementById('ptt-btn');
let recognition = null;
function pttResetButton() {
pttBtn.textContent = '🎙️ Push to talk';
pttBtn.style.background = '#dbeafe';
pttBtn.style.color = '#1e3a8a';
}
function pttSetListening() {
pttBtn.textContent = '🔴 Listening — click to send';
pttBtn.style.background = '#fee';
pttBtn.style.color = '#900';
}
pttBtn.addEventListener('click', async () => {
if (recognition) { try { recognition.stop(); } catch {} return; }
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { setTakeoverStatus('SpeechRecognition not supported in this browser — type instead', true); return; }
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
const ta = document.getElementById('speak-text');
ta.value = '';
let finalText = '';
recognition.onresult = (event) => {
let interim = '';
for (let i = event.resultIndex; i < event.results.length; ++i) {
if (event.results[i].isFinal) finalText += event.results[i][0].transcript;
else interim += event.results[i][0].transcript;
}
ta.value = (finalText + interim).trim();
};
recognition.onerror = (e) => { setTakeoverStatus('mic error: ' + e.error, true); pttResetButton(); recognition = null; };
recognition.onend = async () => {
pttResetButton();
const text = ta.value.trim();
recognition = null;
if (!text) { setTakeoverStatus('(nothing transcribed)', true); return; }
setTakeoverStatus('Sending "' + text.slice(0, 60) + '"…');
try {
const r = await fetch('/api/calls/' + callId + '/say', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ text })
});
const j = await r.json();
if (j.ok) { setTakeoverStatus('✅ queued — your voice plays on next AI turn'); ta.value = ''; }
else setTakeoverStatus('❌ ' + (j.error || 'failed'), true);
} catch (err) { setTakeoverStatus('❌ network error: ' + err.message, true); }
};
try {
recognition.start();
pttSetListening();
setTakeoverStatus('🎙️ listening — speak now, click again to send');
} catch (e) {
setTakeoverStatus('mic start failed: ' + e.message, true);
pttResetButton();
recognition = null;
}
});
// Live AI-agent transcript — polls /api/calls/:id/agent-log every 3s
const feedEl = document.getElementById('transcript-feed');
const countEl2 = document.getElementById('transcript-count');
let lastCount = 0;
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
async function pollAgentLog() {
try {
const r = await fetch('/api/calls/' + callId + '/agent-log', { cache: 'no-store' });
const j = await r.json();
if (!j.ok || !Array.isArray(j.turns)) return;
if (j.turns.length === lastCount) return;
lastCount = j.turns.length;
countEl2.textContent = j.turns.length + ' turn' + (j.turns.length === 1 ? '' : 's');
if (j.turns.length === 0) {
feedEl.innerHTML = '<p class="muted" style="font-style:italic;">No AI-agent turns yet. Twilio may still be in IVR pre-roll.</p>';
return;
}
const html = j.turns.map((t, i) => {
const isHeard = t.kind === 'heard';
const label = isHeard ? 'They said' : 'Butlr replied';
const color = isHeard ? '#5b6770' : '#0c2d6b';
const bg = isHeard ? '#f3f1ec' : '#e6ecf8';
return `<div style="margin-bottom:10px; padding:10px 12px; border-radius:8px; background:${bg};">
<div style="font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:${color}; margin-bottom:4px;">${label}${isHeard && t.turn ? ' · turn ' + t.turn : ''}</div>
<div style="color:#101828;">${escapeHtml(t.text)}</div>
</div>`;
}).join('');
feedEl.innerHTML = html;
feedEl.scrollTop = feedEl.scrollHeight;
} catch {}
}
pollAgentLog();
setInterval(pollAgentLog, 3000);
})();
</script>
<%- include('../partials/footer') %>