Collapse high-frequency audio frames in the demo websocket log.
Expandable groups keep protocol and text events readable while still allowing per-frame inspection when debugging audio traffic. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
* as binary websocket messages.
|
||||
* - Play `response.audio.delta` frames gaplessly through Web Audio.
|
||||
* - Render a chat-style history of user transcripts and bot text deltas.
|
||||
* - Collapse high-frequency audio frames into expandable websocket log groups.
|
||||
*/
|
||||
|
||||
const SAMPLE_RATE = 16000;
|
||||
@@ -16,7 +17,11 @@ const CHANNELS = 1;
|
||||
const FRAME_MS = 20;
|
||||
const PROTOCOL = "va.ws.v1";
|
||||
const MAX_WS_LOG_LINES = 120;
|
||||
const AUDIO_DELTA_LOG_INTERVAL_MS = 1000;
|
||||
const MAX_GROUP_CHILDREN_RENDER = 100;
|
||||
const WS_LOG_GROUP_KEYS = {
|
||||
AUDIO_DELTA: "recv:response.audio.delta",
|
||||
AUDIO_SEND: "send:input.audio",
|
||||
};
|
||||
|
||||
function defaultWsUrl() {
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -70,13 +75,8 @@ const state = {
|
||||
// VU meter smoothing.
|
||||
meterLevel: 0,
|
||||
|
||||
// Compact websocket logging.
|
||||
audioDeltaLogCount: 0,
|
||||
audioDeltaLogBytes: 0,
|
||||
lastAudioDeltaLogAt: 0,
|
||||
audioSendLogCount: 0,
|
||||
audioSendLogBytes: 0,
|
||||
lastAudioSendLogAt: 0,
|
||||
// Collapsible websocket log groups for high-frequency audio frames.
|
||||
wsLogGroup: null,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ UI */
|
||||
@@ -169,6 +169,209 @@ function truncateLogValue(value, maxLength = 160) {
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function formatLogTime(date = new Date()) {
|
||||
return date.toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatLogBytes(byteCount) {
|
||||
if (byteCount >= 1048576) {
|
||||
return `${(byteCount / 1048576).toFixed(2)} MB`;
|
||||
}
|
||||
if (byteCount >= 1024) {
|
||||
return `${(byteCount / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${byteCount} bytes`;
|
||||
}
|
||||
|
||||
function wsLogGroupLabel(groupKey) {
|
||||
if (groupKey === WS_LOG_GROUP_KEYS.AUDIO_DELTA) {
|
||||
return "response.audio.delta";
|
||||
}
|
||||
if (groupKey === WS_LOG_GROUP_KEYS.AUDIO_SEND) {
|
||||
return "input.audio binary";
|
||||
}
|
||||
return "grouped events";
|
||||
}
|
||||
|
||||
function ensureWsLogReady() {
|
||||
if (els.wsLog.querySelector(".ws-log__empty")) {
|
||||
els.wsLog.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function scrollWsLogToBottom() {
|
||||
els.wsLog.scrollTop = els.wsLog.scrollHeight;
|
||||
}
|
||||
|
||||
function trimWsLog() {
|
||||
while (els.wsLog.children.length > MAX_WS_LOG_LINES) {
|
||||
const first = els.wsLog.firstElementChild;
|
||||
if (state.wsLogGroup?.element === first) {
|
||||
state.wsLogGroup = null;
|
||||
}
|
||||
first.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeWsLogGroup() {
|
||||
state.wsLogGroup = null;
|
||||
}
|
||||
|
||||
function createWsLogEntry(direction, detail, kind, timeText = formatLogTime()) {
|
||||
const entry = document.createElement("div");
|
||||
entry.className = `ws-log__entry ws-log__entry--${kind}`;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "ws-log__time";
|
||||
time.textContent = timeText;
|
||||
|
||||
const dir = document.createElement("span");
|
||||
dir.className = "ws-log__direction";
|
||||
dir.textContent =
|
||||
direction === "send"
|
||||
? "SEND"
|
||||
: direction === "recv"
|
||||
? "RECV"
|
||||
: direction.toUpperCase();
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "ws-log__detail";
|
||||
body.textContent = detail;
|
||||
|
||||
entry.append(time, dir, body);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function updateWsLogGroupSummary(group) {
|
||||
group.summaryEl.textContent = `${wsLogGroupLabel(group.key)} ×${group.count} (${formatLogBytes(group.totalBytes)})`;
|
||||
}
|
||||
|
||||
function appendWsLogGroupChildDom(group, item) {
|
||||
const entry = createWsLogEntry(
|
||||
group.direction,
|
||||
item.detail,
|
||||
group.kind,
|
||||
item.time,
|
||||
);
|
||||
entry.classList.add("ws-log__entry--child");
|
||||
group.childrenEl.appendChild(entry);
|
||||
|
||||
const childEntries = group.childrenEl.querySelectorAll(".ws-log__entry");
|
||||
if (childEntries.length > MAX_GROUP_CHILDREN_RENDER) {
|
||||
const omit = group.childrenEl.querySelector(".ws-log__group-omit");
|
||||
if (!omit) {
|
||||
const omitted = document.createElement("div");
|
||||
omitted.className = "ws-log__group-omit";
|
||||
omitted.textContent = "… earlier events omitted";
|
||||
group.childrenEl.insertBefore(omitted, group.childrenEl.firstElementChild);
|
||||
}
|
||||
childEntries[0].remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderWsLogGroupChildren(group) {
|
||||
group.childrenEl.innerHTML = "";
|
||||
const items = group.items;
|
||||
const start = Math.max(0, items.length - MAX_GROUP_CHILDREN_RENDER);
|
||||
if (start > 0) {
|
||||
const omitted = document.createElement("div");
|
||||
omitted.className = "ws-log__group-omit";
|
||||
omitted.textContent = `… ${start} earlier events omitted`;
|
||||
group.childrenEl.appendChild(omitted);
|
||||
}
|
||||
for (let i = start; i < items.length; i += 1) {
|
||||
appendWsLogGroupChildDom(group, items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWsLogGroup(group) {
|
||||
group.collapsed = !group.collapsed;
|
||||
group.childrenEl.hidden = group.collapsed;
|
||||
group.chevronEl.textContent = group.collapsed ? "▶" : "▼";
|
||||
group.headerEl.setAttribute("aria-expanded", group.collapsed ? "false" : "true");
|
||||
|
||||
if (!group.collapsed && group.childrenEl.childElementCount === 0) {
|
||||
renderWsLogGroupChildren(group);
|
||||
}
|
||||
}
|
||||
|
||||
function appendWsLogGroupItem(groupKey, direction, kind, itemDetail, byteCount = 0) {
|
||||
ensureWsLogReady();
|
||||
|
||||
let group = state.wsLogGroup;
|
||||
if (!group || group.key !== groupKey) {
|
||||
finalizeWsLogGroup();
|
||||
|
||||
const groupEl = document.createElement("div");
|
||||
groupEl.className = `ws-log__group ws-log__group--${kind}`;
|
||||
|
||||
const header = document.createElement("button");
|
||||
header.type = "button";
|
||||
header.className = "ws-log__group-header";
|
||||
header.setAttribute("aria-expanded", "false");
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "ws-log__time";
|
||||
time.textContent = formatLogTime();
|
||||
|
||||
const dir = document.createElement("span");
|
||||
dir.className = "ws-log__direction";
|
||||
dir.textContent = direction === "send" ? "SEND" : "RECV";
|
||||
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = "ws-log__group-chevron";
|
||||
chevron.textContent = "▶";
|
||||
chevron.setAttribute("aria-hidden", "true");
|
||||
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "ws-log__group-summary";
|
||||
|
||||
header.append(time, dir, chevron, summary);
|
||||
|
||||
const children = document.createElement("div");
|
||||
children.className = "ws-log__group-children";
|
||||
children.hidden = true;
|
||||
|
||||
groupEl.append(header, children);
|
||||
els.wsLog.appendChild(groupEl);
|
||||
|
||||
group = {
|
||||
key: groupKey,
|
||||
direction,
|
||||
kind,
|
||||
element: groupEl,
|
||||
headerEl: header,
|
||||
chevronEl: chevron,
|
||||
summaryEl: summary,
|
||||
childrenEl: children,
|
||||
collapsed: true,
|
||||
count: 0,
|
||||
totalBytes: 0,
|
||||
items: [],
|
||||
};
|
||||
state.wsLogGroup = group;
|
||||
header.addEventListener("click", () => toggleWsLogGroup(group));
|
||||
}
|
||||
|
||||
group.count += 1;
|
||||
group.totalBytes += byteCount;
|
||||
const item = { time: formatLogTime(), detail: itemDetail };
|
||||
group.items.push(item);
|
||||
updateWsLogGroupSummary(group);
|
||||
|
||||
if (!group.collapsed) {
|
||||
appendWsLogGroupChildDom(group, item);
|
||||
}
|
||||
|
||||
trimWsLog();
|
||||
scrollWsLogToBottom();
|
||||
}
|
||||
|
||||
function compactWsPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") return String(payload);
|
||||
const compact = { ...payload };
|
||||
@@ -191,85 +394,27 @@ function compactWsPayload(payload) {
|
||||
}
|
||||
|
||||
function addWsLog(direction, detail, kind = direction) {
|
||||
if (els.wsLog.querySelector(".ws-log__empty")) {
|
||||
els.wsLog.innerHTML = "";
|
||||
}
|
||||
|
||||
const entry = document.createElement("div");
|
||||
entry.className = `ws-log__entry ws-log__entry--${kind}`;
|
||||
|
||||
const time = document.createElement("span");
|
||||
time.className = "ws-log__time";
|
||||
time.textContent = new Date().toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
const dir = document.createElement("span");
|
||||
dir.className = "ws-log__direction";
|
||||
dir.textContent =
|
||||
direction === "send"
|
||||
? "SEND"
|
||||
: direction === "recv"
|
||||
? "RECV"
|
||||
: direction.toUpperCase();
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "ws-log__detail";
|
||||
body.textContent = detail;
|
||||
|
||||
entry.append(time, dir, body);
|
||||
els.wsLog.appendChild(entry);
|
||||
|
||||
while (els.wsLog.children.length > MAX_WS_LOG_LINES) {
|
||||
els.wsLog.firstElementChild.remove();
|
||||
}
|
||||
els.wsLog.scrollTop = els.wsLog.scrollHeight;
|
||||
}
|
||||
|
||||
function flushAudioDeltaLog() {
|
||||
if (state.audioDeltaLogCount === 0) return;
|
||||
addWsLog(
|
||||
"recv",
|
||||
`response.audio.delta x${state.audioDeltaLogCount} (${state.audioDeltaLogBytes} bytes)`,
|
||||
);
|
||||
state.audioDeltaLogCount = 0;
|
||||
state.audioDeltaLogBytes = 0;
|
||||
state.lastAudioDeltaLogAt = performance.now();
|
||||
}
|
||||
|
||||
function flushAudioSendLog() {
|
||||
if (state.audioSendLogCount === 0) return;
|
||||
addWsLog(
|
||||
"send",
|
||||
`input.audio binary x${state.audioSendLogCount} (${state.audioSendLogBytes} bytes)`,
|
||||
);
|
||||
state.audioSendLogCount = 0;
|
||||
state.audioSendLogBytes = 0;
|
||||
state.lastAudioSendLogAt = performance.now();
|
||||
}
|
||||
|
||||
function flushPendingWsLogs() {
|
||||
flushAudioDeltaLog();
|
||||
flushAudioSendLog();
|
||||
finalizeWsLogGroup();
|
||||
ensureWsLogReady();
|
||||
els.wsLog.appendChild(createWsLogEntry(direction, detail, kind));
|
||||
trimWsLog();
|
||||
scrollWsLogToBottom();
|
||||
}
|
||||
|
||||
function logWsPayload(direction, payload) {
|
||||
if (direction === "send") {
|
||||
flushAudioSendLog();
|
||||
} else {
|
||||
flushAudioDeltaLog();
|
||||
}
|
||||
|
||||
if (direction === "recv" && payload?.type === "response.audio.delta") {
|
||||
state.audioDeltaLogCount += 1;
|
||||
state.audioDeltaLogBytes += payload.bytes || payload.audio?.length || 0;
|
||||
const now = performance.now();
|
||||
if (now - state.lastAudioDeltaLogAt >= AUDIO_DELTA_LOG_INTERVAL_MS) {
|
||||
flushAudioDeltaLog();
|
||||
}
|
||||
const bytes = payload.bytes || 0;
|
||||
const detail =
|
||||
payload.seq != null
|
||||
? `seq=${payload.seq} (${bytes} bytes)`
|
||||
: `(${bytes} bytes)`;
|
||||
appendWsLogGroupItem(
|
||||
WS_LOG_GROUP_KEYS.AUDIO_DELTA,
|
||||
"recv",
|
||||
"recv",
|
||||
detail,
|
||||
bytes,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,12 +422,13 @@ function logWsPayload(direction, payload) {
|
||||
}
|
||||
|
||||
function logBinarySend(byteLength) {
|
||||
state.audioSendLogCount += 1;
|
||||
state.audioSendLogBytes += byteLength;
|
||||
const now = performance.now();
|
||||
if (now - state.lastAudioSendLogAt >= AUDIO_DELTA_LOG_INTERVAL_MS) {
|
||||
flushAudioSendLog();
|
||||
}
|
||||
appendWsLogGroupItem(
|
||||
WS_LOG_GROUP_KEYS.AUDIO_SEND,
|
||||
"send",
|
||||
"send",
|
||||
`(${byteLength} bytes)`,
|
||||
byteLength,
|
||||
);
|
||||
}
|
||||
|
||||
function wsSend(data) {
|
||||
@@ -292,8 +438,6 @@ function wsSend(data) {
|
||||
try {
|
||||
logWsPayload("send", JSON.parse(data));
|
||||
} catch (_) {
|
||||
flushAudioSendLog();
|
||||
flushAudioDeltaLog();
|
||||
addWsLog("send", truncateLogValue(data));
|
||||
}
|
||||
} else {
|
||||
@@ -313,10 +457,7 @@ function wsSend(data) {
|
||||
}
|
||||
|
||||
function clearWsLog() {
|
||||
state.audioDeltaLogCount = 0;
|
||||
state.audioDeltaLogBytes = 0;
|
||||
state.audioSendLogCount = 0;
|
||||
state.audioSendLogBytes = 0;
|
||||
state.wsLogGroup = null;
|
||||
els.wsLog.innerHTML =
|
||||
'<div class="ws-log__empty">No websocket events yet.</div>';
|
||||
}
|
||||
@@ -450,7 +591,6 @@ function stopMic() {
|
||||
state.micEnabled = false;
|
||||
updateMeter(0);
|
||||
if (wasEnabled) {
|
||||
flushAudioSendLog();
|
||||
addWsLog("system", "mic capture stopped");
|
||||
}
|
||||
setMicButton();
|
||||
@@ -752,7 +892,7 @@ async function connect() {
|
||||
setMicSelectEnabled();
|
||||
setComposerEnabled(false);
|
||||
setBotIndicator(false);
|
||||
flushPendingWsLogs();
|
||||
finalizeWsLogGroup();
|
||||
addWsLog(
|
||||
"system",
|
||||
`websocket close code=${event.code}${
|
||||
|
||||
@@ -405,6 +405,79 @@ body {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.ws-log__group {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ws-log__group-header {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 42px 14px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 5px 4px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ws-log__group-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.ws-log__group-header:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ws-log__group-chevron {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ws-log__group-summary {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ws-log__group-children {
|
||||
margin: 0 0 4px 18px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ws-log__entry--child {
|
||||
grid-template-columns: 58px 42px minmax(0, 1fr);
|
||||
opacity: 0.85;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ws-log__group-omit {
|
||||
padding: 4px 4px 4px 0;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.ws-log__group--send .ws-log__direction {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ws-log__group--recv .ws-log__direction {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* Controls -------------------------------------------------------------- */
|
||||
|
||||
.controls {
|
||||
@@ -720,10 +793,15 @@ body {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ws-log__entry {
|
||||
.ws-log__entry,
|
||||
.ws-log__group-header {
|
||||
grid-template-columns: 54px 38px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ws-log__group-header {
|
||||
grid-template-columns: 54px 38px 12px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.status {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user