<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Voice AI Debugger</title>
<style>
:root {
--bg-color: #0f0f0f;
--panel-bg: #1a1a1a;
--border-color: #2b2b2b;
--text-color: #e0e0e0;
--text-muted: #888;
--primary-color: #3b82f6;
--success-color: #22c55e;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--user-color: #4ec9b0;
--bot-color: #569cd6;
--llm-color: #c586c0;
--asr-color: #9cdcfe;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: var(--bg-color);
color: var(--text-color);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--panel-bg);
border-bottom: 1px solid var(--border-color);
gap: 20px;
}
.brand h1 {
font-size: 18px;
font-weight: 600;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
flex-grow: 1;
justify-content: flex-end;
}
select,
input {
background: #252526;
border: 1px solid var(--border-color);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
}
button {
padding: 6px 14px;
border-radius: 4px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-secondary {
background: #3c3c3c;
color: white;
}
.status-pill {
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
}
.status-idle {
background: #333;
color: #888;
}
.status-connected {
background: #064e3b;
color: #34d399;
}
.status-calling {
background: #78350f;
color: #fbbf24;
}
main {
display: grid;
grid-template-columns: 350px 1fr 350px;
grid-template-rows: auto 1fr;
flex-grow: 1;
overflow: hidden;
}
.timeline-container {
grid-column: 1 / -1;
background: #141414;
border-bottom: 1px solid var(--border-color);
height: 140px;
overflow-x: auto;
position: relative;
}
.timeline-ruler {
position: absolute;
top: 0;
left: 60px;
height: 20px;
border-bottom: 1px solid #333;
pointer-events: none;
z-index: 5;
}
.ruler-mark {
position: absolute;
border-left: 1px solid #333;
height: 100%;
font-size: 9px;
color: #555;
padding-left: 4px;
white-space: nowrap;
}
.timeline-rows {
display: flex;
flex-direction: column;
gap: 4px;
width: fit-content;
min-width: 100%;
position: relative;
padding-top: 25px;
}
.timeline-row {
height: 20px;
display: flex;
align-items: center;
position: relative;
}
.timeline-label {
position: sticky;
left: 0;
font-size: 10px;
font-weight: 700;
color: #666;
width: 60px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: #141414;
border-right: 1px solid #222;
}
.timeline-bar {
position: absolute;
height: 14px;
border-radius: 3px;
min-width: 2px;
cursor: pointer;
transition: transform 0.1s;
}
.timeline-bar:hover {
transform: scaleY(1.2);
filter: brightness(1.2);
}
.bar-vad {
background: var(--user-color);
opacity: 0.8;
}
.bar-asr {
background: var(--asr-color);
opacity: 0.8;
}
.bar-llm {
background: var(--llm-color);
opacity: 0.8;
}
.bar-tts {
background: var(--bot-color);
opacity: 0.8;
}
.playbook-area {
background: #111;
border-right: 1px solid var(--border-color);
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-area {
overflow-y: auto;
border-right: 1px solid var(--border-color);
padding: 20px;
display: flex;
flex-direction: column;
gap: 15px;
}
.sidebar-area {
background: #161616;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 10px 15px;
background: #1e1e1e;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 5;
}
.panel-header h2 {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
}
.chat-container {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 80%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
.message.user {
align-self: flex-start;
background: #2a2a2a;
border-left: 3px solid var(--user-color);
}
.message.assistant {
align-self: flex-end;
background: #1e3a8a33;
border-right: 3px solid var(--bot-color);
}
.msg-info {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
}
.msg-metrics {
margin-top: 6px;
display: flex;
gap: 8px;
font-size: 10px;
color: var(--text-muted);
border-top: 1px border #333;
padding-top: 4px;
}
.metric {
background: #252526;
padding: 2px 6px;
border-radius: 4px;
}
.log-container {
padding: 10px;
font-family: monospace;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 8px;
border-bottom: 1px solid #222;
padding: 2px 0;
}
.log-k {
font-weight: 600;
width: 80px;
}
.type-asr {
color: var(--asr-color);
}
.type-tts {
color: var(--bot-color);
}
.type-llm {
color: var(--llm-color);
}
.type-err {
color: var(--danger-color);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 10px;
}
</style>
</head>
<body>
<header>
<div class="brand">
<h1>🎠Playbook Debugger</h1>
<div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">
Power By <a href="https://miuda.ai" target="_blank"
style="color: var(--primary-color); text-decoration: none;">miuda.ai</a>
</div>
</div>
<div class="controls">
<a href="https://github.com/miuda-ai/active-call" target="_blank" title="GitHub Repository"
style="margin-right: 15px; color: var(--text-muted); transition: color 0.2s;">
<svg height="22" viewBox="0 0 16 16" width="22" std=""
style="fill: currentColor; vertical-align: middle;">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z">
</path>
</svg>
</a>
<select id="playbook-list">
<option value="" disabled selected>Select Playbook...</option>
</select>
<input type="text" id="sip-uri" placeholder="sip:user@host" style="width: 200px;">
<button id="run-btn" class="btn-primary" disabled>WebRTC</button>
<button id="sip-call-btn" class="btn-primary" disabled>SIP</button>
<button id="hangup-btn" class="btn-danger" disabled>Hangup</button>
<span id="status" class="status-pill status-idle">Idle</span>
</div>
</header>
<main>
<div id="timeline-box" class="timeline-container">
<div id="timeline-ruler" class="timeline-ruler"></div>
<div id="timeline-rows" class="timeline-rows">
<div class="timeline-row" data-lane="USER">
<div class="timeline-label">USER</div>
</div>
<div class="timeline-row" data-lane="ASR">
<div class="timeline-label">ASR</div>
</div>
<div class="timeline-row" data-lane="LLM">
<div class="timeline-label">LLM</div>
</div>
<div class="timeline-row" data-lane="BOT">
<div class="timeline-label">BOT</div>
</div>
</div>
</div>
<div class="playbook-area">
<div class="panel-header">
<h2>Playbook Editor</h2>
<div style="display: flex; gap: 4px;">
<button class="btn-secondary" style="font-size: 10px;" onclick="savePlaybook()">Save</button>
<button class="btn-secondary" style="font-size: 10px;"
onclick="document.getElementById('playbook-editor').value = ''">Clear</button>
</div>
</div>
<textarea id="playbook-editor"
style="width: 100%; flex-grow: 1; background: #111; color: #ccc; border: none; padding: 12px; font-family: 'Fira Code', monospace; font-size: 12px; resize: none; outline: none; line-height: 1.6;"
placeholder="Paste markdown playbook here..."></textarea>
</div>
<div class="content-area">
<div class="panel-header">
<h2>Conversation</h2><button class="btn-secondary" style="font-size: 10px;"
onclick="clearUI()">Clear</button>
</div>
<div id="conversation" class="chat-container"></div>
</div>
<div class="sidebar-area">
<div class="panel-header">
<h2>Events</h2>
</div>
<div id="events" class="log-container"></div>
</div>
</main>
<script>
let selectedPlaybook = null, ws = null, pc = null, timelineStart = null;
const PIXEL_PER_MS = 0.05; // 1s = 50px
const timelineLanes = {
USER: document.querySelector('[data-lane="USER"]'),
ASR: document.querySelector('[data-lane="ASR"]'),
LLM: document.querySelector('[data-lane="LLM"]'),
BOT: document.querySelector('[data-lane="BOT"]')
};
let activeBars = { USER: null, ASR: null, LLM: null, BOT: null };
let rulerMarksSet = new Set();
let messageMetrics = {};
let latestAssistantTs = null;
let playIdToTs = {};
let playIdMetrics = {};
let textToTs = {};
async function loadPlaybooks() {
try {
const res = await fetch('/api/playbooks');
const playbooks = await res.json();
const list = document.getElementById('playbook-list');
playbooks.forEach(p => {
const opt = document.createElement('option');
opt.value = p.name; opt.textContent = p.name;
list.appendChild(opt);
});
list.onchange = async (e) => {
selectedPlaybook = e.target.value;
document.getElementById('run-btn').disabled = false;
document.getElementById('sip-call-btn').disabled = false;
// Fetch playbook content
try {
const contentRes = await fetch(`/api/playbooks/${selectedPlaybook}`);
if (contentRes.ok) {
const content = await contentRes.text();
document.getElementById('playbook-editor').value = content;
}
} catch (err) {
console.error('Failed to fetch playbook content', err);
}
};
} catch (e) { logEntry('ERROR', e); }
}
async function savePlaybook() {
if (!selectedPlaybook) return;
const content = document.getElementById('playbook-editor').value;
try {
const res = await fetch(`/api/playbooks/${selectedPlaybook}`, {
method: 'POST',
body: content
});
if (res.ok) {
alert('Playbook saved successfully');
} else {
alert('Failed to save playbook');
}
} catch (err) {
alert('Error saving playbook: ' + err.message);
}
}
function updateRuler(ts) {
if (!timelineStart) timelineStart = ts;
const elapsedSec = Math.floor((ts - timelineStart) / 1000);
for (let s = 0; s <= elapsedSec + 10; s++) {
if (!rulerMarksSet.has(s)) {
const mark = document.createElement('div');
mark.className = 'ruler-mark';
mark.style.left = (s * 1000 * PIXEL_PER_MS) + 'px';
mark.textContent = s + 's';
document.getElementById('timeline-ruler').appendChild(mark);
rulerMarksSet.add(s);
}
}
const totalWidth = (elapsedSec + 12) * 1000 * PIXEL_PER_MS + 100;
document.getElementById('timeline-rows').style.width = totalWidth + 'px';
document.getElementById('timeline-ruler').style.width = totalWidth + 'px';
}
function createBar(laneName, startTime, colorClass) {
if (!timelineStart) timelineStart = startTime;
updateRuler(startTime);
const bar = document.createElement('div');
bar.className = `timeline-bar ${colorClass}`;
const left = (startTime - timelineStart) * PIXEL_PER_MS + 60;
bar.style.left = left + 'px';
bar.style.width = '2px';
timelineLanes[laneName].appendChild(bar);
const barObj = { el: bar, start: startTime, jumpTs: null };
bar.onclick = () => { if (barObj.jumpTs) jumpToMessage(barObj.jumpTs); };
return barObj;
}
function endBar(barObj, endTime) {
if (!barObj) return;
updateRuler(endTime);
const width = Math.max(2, (endTime - barObj.start) * PIXEL_PER_MS);
barObj.el.style.width = width + 'px';
}
function handleEvent(event) {
const type = (event.event || 'metrics').toLowerCase();
const ts = event.timestamp;
logEntry(type, JSON.stringify(event));
updateRuler(ts);
if (type === 'speaking') {
activeBars.USER = createBar('USER', ts, 'bar-vad');
activeBars.ASR = createBar('ASR', ts, 'bar-asr');
activeBars.lastUserBars = [activeBars.USER, activeBars.ASR];
} else if (type === 'silence') {
endBar(activeBars.USER, ts); activeBars.USER = null;
} else if (type === 'asrfinal') {
endBar(activeBars.ASR, ts); activeBars.ASR = null;
const asrLatency = event.endTime ? Math.max(0, event.timestamp - event.endTime) : null;
addMessage('user', event.text, ts, asrLatency ? { asr: asrLatency } : null);
if (activeBars.lastUserBars) activeBars.lastUserBars.forEach(b => b.jumpTs = ts);
} else if (type === 'trackstart') {
activeBars.BOT = createBar('BOT', ts, 'bar-tts');
if (event.playId) {
activeBars.BOT.playId = event.playId;
if (playIdToTs[event.playId]) activeBars.BOT.jumpTs = playIdToTs[event.playId];
}
if (!activeBars.BOT.jumpTs && latestAssistantTs) activeBars.BOT.jumpTs = latestAssistantTs;
} else if (type === 'trackend') {
endBar(activeBars.BOT, ts); activeBars.BOT = null;
} else if (type === 'metrics') {
if (event.key === 'tts_play_id_map') {
const playId = event.data.playId;
const text = event.data.text ? event.data.text.trim() : null;
if (playId && text && textToTs[text]) {
playIdToTs[playId] = textToTs[text];
if (playIdMetrics[playId]) {
addMessage('assistant', null, textToTs[text], playIdMetrics[playId]);
}
}
} else if (event.key === 'llm_call_start') {
activeBars.LLM = createBar('LLM', ts, 'bar-llm');
if (event.data.playId) activeBars.LLM.playId = event.data.playId;
} else if (event.key === 'llm_response') {
endBar(activeBars.LLM, ts);
const playId = event.data.playId;
if (playId) playIdToTs[playId] = ts;
const metrics = {
llm: event.data.duration,
llm_ttfb: event.data.ttfb
};
if (playId && playIdMetrics[playId]) Object.assign(metrics, playIdMetrics[playId]);
addMessage('assistant', event.data.response, ts, metrics);
if (activeBars.LLM) activeBars.LLM.jumpTs = ts;
if (activeBars.BOT && (activeBars.BOT.playId === playId)) activeBars.BOT.jumpTs = ts;
activeBars.LLM = null;
} else if (event.key.startsWith('completed.tts.')) {
const playId = event.data.playId;
const metrics = {
tts: event.duration,
tts_ttfb: event.data.ttfb
};
if (playId && playIdToTs[playId]) {
addMessage('assistant', null, playIdToTs[playId], metrics);
} else if (playId) {
playIdMetrics[playId] = Object.assign(playIdMetrics[playId] || {}, metrics);
}
}
} else if (type === 'addhistory') {
addMessage(event.speaker === 'user' ? 'user' : 'assistant', event.text, ts);
}
}
function jumpToMessage(ts) {
const el = document.getElementById(`msg-${ts}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.boxShadow = '0 0 0 3px var(--primary-color)';
setTimeout(() => el.style.boxShadow = 'none', 2000);
}
}
function addMessage(role, text, ts, metrics = null) {
if (role === 'assistant') latestAssistantTs = ts;
if (text) textToTs[text.trim()] = ts;
if (metrics) {
messageMetrics[ts] = { ...(messageMetrics[ts] || {}), ...metrics };
}
const currentMetrics = messageMetrics[ts];
const container = document.getElementById('conversation');
let div = document.getElementById(`msg-${ts}`);
if (!div) {
div = document.createElement('div');
div.className = `message ${role}`;
div.id = `msg-${ts}`;
container.appendChild(div);
}
if (text !== null) {
div.dataset.text = text;
}
const displayText = div.dataset.text || '';
let metricsHtml = '';
if (currentMetrics) {
metricsHtml = `<div class="msg-metrics">` +
(currentMetrics.asr !== undefined ? `<span class="metric">ASR: ${currentMetrics.asr}ms</span>` : '') +
(currentMetrics.llm !== undefined ? `<span class="metric">LLM: ${currentMetrics.llm}ms</span>` : '') +
(currentMetrics.llm_ttfb !== undefined ? `<span class="metric">LLM TTFB: ${currentMetrics.llm_ttfb}ms</span>` : '') +
(currentMetrics.tts !== undefined ? `<span class="metric">TTS: ${currentMetrics.tts}ms</span>` : '') +
(currentMetrics.tts_ttfb !== undefined ? `<span class="metric">TTS TTFB: ${currentMetrics.tts_ttfb}ms</span>` : '') +
`</div>`;
}
div.innerHTML = `<div class="msg-info">${role.toUpperCase()} • ${new Date(ts).toLocaleTimeString()}</div><div class="msg-text">${displayText}</div>${metricsHtml}`;
if (text !== null) container.scrollTop = container.scrollHeight;
}
function logEntry(key, val) {
const container = document.getElementById('events');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="log-k">${key}</span><span class="log-v">${val}</span>`;
container.prepend(entry);
}
function updateStatus(status) {
const el = document.getElementById('status');
el.className = `status-pill status-${status}`;
el.textContent = status;
}
function clearUI() {
document.getElementById('conversation').innerHTML = '';
document.getElementById('events').innerHTML = '';
document.getElementById('timeline-ruler').innerHTML = '';
rulerMarksSet.clear();
Object.values(timelineLanes).forEach(lane => {
lane.querySelectorAll('.timeline-bar').forEach(b => b.remove());
});
timelineStart = null;
activeBars = { USER: null, ASR: null, LLM: null, BOT: null };
}
document.getElementById('playbook-editor').oninput = () => {
const hasContent = document.getElementById('playbook-editor').value.trim().length > 0;
document.getElementById('run-btn').disabled = !hasContent;
document.getElementById('sip-call-btn').disabled = !hasContent;
};
document.getElementById('run-btn').onclick = async () => {
const content = document.getElementById('playbook-editor').value;
const res = await fetch('/api/playbook/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content, type: 'webrtc' })
});
const data = await res.json(); startCall(data.session_id);
};
document.getElementById('sip-call-btn').onclick = async () => {
const callee = document.getElementById('sip-uri').value;
const content = document.getElementById('playbook-editor').value;
const res = await fetch('/api/playbook/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content, type: 'sip', to: callee })
});
const data = await res.json(); startSipCall(data.session_id, callee);
};
document.getElementById('hangup-btn').onclick = () => {
if (ws) {
ws.send(JSON.stringify({ command: 'hangup' }));
ws.close();
}
if (pc) pc.close();
};
async function startSipCall(sessionId, callee) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/call/sip?id=${sessionId}`);
ws.onopen = () => { updateStatus('connected'); document.getElementById('hangup-btn').disabled = false; ws.send(JSON.stringify({ command: 'invite', option: { callee: callee } })); };
ws.onmessage = (e) => { const msg = JSON.parse(e.data); handleEvent(msg); if (msg.event === 'answer') updateStatus('calling'); };
ws.onclose = () => { updateStatus('idle'); document.getElementById('hangup-btn').disabled = true; };
}
async function startCall(sessionId) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const iceServers = await fetch('/iceservers').then(r => r.json());
pc = new RTCPeerConnection({ iceServers });
stream.getTracks().forEach(t => pc.addTrack(t, stream));
pc.ontrack = (e) => { const audio = new Audio(); audio.srcObject = e.streams[0]; audio.autoplay = true; };
const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/call/webrtc?id=${sessionId}`);
ws.onopen = () => { updateStatus('connected'); document.getElementById('hangup-btn').disabled = false; ws.send(JSON.stringify({ command: 'invite', option: { offer: offer.sdp } })); };
ws.onmessage = async (e) => {
const msg = JSON.parse(e.data); handleEvent(msg);
if (msg.event === 'answer') { await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }); updateStatus('calling'); }
};
ws.onclose = () => { updateStatus('idle'); document.getElementById('hangup-btn').disabled = true; };
}
loadPlaybooks();
</script>
</body>
</html>