diff --git a/examples/webpage/app.js b/examples/webpage/app.js index a41e216..140775e 100644 --- a/examples/webpage/app.js +++ b/examples/webpage/app.js @@ -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 = '