diff --git a/examples/webpage/app.js b/examples/webpage/app.js index d5a46bb..7d3638c 100644 --- a/examples/webpage/app.js +++ b/examples/webpage/app.js @@ -69,6 +69,9 @@ const state = { audioDeltaLogCount: 0, audioDeltaLogBytes: 0, lastAudioDeltaLogAt: 0, + audioSendLogCount: 0, + audioSendLogBytes: 0, + lastAudioSendLogAt: 0, }; /* ------------------------------------------------------------------ UI */ @@ -201,7 +204,12 @@ function addWsLog(direction, detail, kind = direction) { const dir = document.createElement("span"); dir.className = "ws-log__direction"; - dir.textContent = direction; + dir.textContent = + direction === "send" + ? "SEND" + : direction === "recv" + ? "RECV" + : direction.toUpperCase(); const body = document.createElement("span"); body.className = "ws-log__detail"; @@ -227,8 +235,30 @@ function flushAudioDeltaLog() { 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(); +} + function logWsPayload(direction, payload) { - if (payload?.type === "response.audio.delta") { + 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(); @@ -238,13 +268,50 @@ function logWsPayload(direction, payload) { return; } - flushAudioDeltaLog(); addWsLog(direction, compactWsPayload(payload)); } +function logBinarySend(byteLength) { + state.audioSendLogCount += 1; + state.audioSendLogBytes += byteLength; + const now = performance.now(); + if (now - state.lastAudioSendLogAt >= AUDIO_DELTA_LOG_INTERVAL_MS) { + flushAudioSendLog(); + } +} + +function wsSend(data) { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false; + + if (typeof data === "string") { + try { + logWsPayload("send", JSON.parse(data)); + } catch (_) { + flushAudioSendLog(); + flushAudioDeltaLog(); + addWsLog("send", truncateLogValue(data)); + } + } else { + const byteLength = + data instanceof ArrayBuffer + ? data.byteLength + : ArrayBuffer.isView(data) + ? data.byteLength + : 0; + if (byteLength > 0) { + logBinarySend(byteLength); + } + } + + state.ws.send(data); + return true; +} + function clearWsLog() { state.audioDeltaLogCount = 0; state.audioDeltaLogBytes = 0; + state.audioSendLogCount = 0; + state.audioSendLogBytes = 0; els.wsLog.innerHTML = '
No websocket events yet.
'; } @@ -335,14 +402,14 @@ async function startMic() { const data = event.data; if (!data || data.type !== "frame") return; updateMeter(data.rms || 0); - if (state.connected && state.ws && state.ws.readyState === WebSocket.OPEN) { - state.ws.send(data.buffer); + if (state.connected) { + wsSend(data.buffer); } }; state.micSourceNode.connect(state.recorderNode); state.micEnabled = true; - addWsLog("send", "input.audio binary stream started"); + addWsLog("system", "mic capture started (binary input.audio frames)"); setMicButton(); } @@ -378,7 +445,8 @@ function stopMic() { state.micEnabled = false; updateMeter(0); if (wasEnabled) { - addWsLog("send", "input.audio binary stream stopped"); + flushAudioSendLog(); + addWsLog("system", "mic capture stopped"); } setMicButton(); } @@ -483,8 +551,7 @@ function sendText(text) { // `response.text.final(interrupted=true)` for the in-flight assistant // turn, which finalizes that bubble in place. A brand-new bubble for the // reply will be created when `response.text.started` arrives. - state.ws.send(JSON.stringify(message)); - logWsPayload("send", message); + wsSend(JSON.stringify(message)); stopPlaybackQueue(); addBubble("user", value); return true; @@ -629,8 +696,7 @@ async function connect() { setMicSelectEnabled(); refreshMicDevices(); - ws.send(JSON.stringify(startMessage)); - logWsPayload("send", startMessage); + wsSend(JSON.stringify(startMessage)); addBubble("system", "Session started."); setComposerEnabled(true); els.textInput.focus(); @@ -678,7 +744,7 @@ async function connect() { setMicSelectEnabled(); setComposerEnabled(false); setBotIndicator(false); - flushAudioDeltaLog(); + flushPendingWsLogs(); addWsLog( "system", `websocket close code=${event.code}${ @@ -702,8 +768,7 @@ function disconnect() { try { if (state.ws.readyState === WebSocket.OPEN) { const stopMessage = { type: "session.stop", reason: "client_disconnect" }; - state.ws.send(JSON.stringify(stopMessage)); - logWsPayload("send", stopMessage); + wsSend(JSON.stringify(stopMessage)); } } catch (_) { /* ignore */ diff --git a/examples/webpage/index.html b/examples/webpage/index.html index 440e078..925f326 100644 --- a/examples/webpage/index.html +++ b/examples/webpage/index.html @@ -36,111 +36,121 @@ -
-
-
-

Connect to the engine, enable your mic, and start talking.

-

- Audio is streamed as PCM16 mono @ 16 kHz over - /ws-product. +

+
+
+
+
+

Connect to the engine, enable your mic, and start talking.

+

+ Audio is streamed as PCM16 mono @ 16 kHz over + /ws-product. +

+
+
+
+ +
+ + +
+ + +
+ +
+ + + + +
+ + + Mic + + + + Bot + +
+ + +
+ +

+ Press Enter to send, Shift+Enter + for newline. Sending text will interrupt the bot if it's speaking. + Browser echo cancellation is on; use headphones if echo persists.

+
+
+ +
+
+
+

WebSocket Log

+ +
+
-
-
- -
-
-

WebSocket Log

- -
-
-
No websocket events yet.
-
-
- - + + diff --git a/examples/webpage/styles.css b/examples/webpage/styles.css index b19ecc0..28ede32 100644 --- a/examples/webpage/styles.css +++ b/examples/webpage/styles.css @@ -45,30 +45,49 @@ body { body { display: flex; justify-content: center; - padding: 24px; + padding: 12px; } .app { display: grid; - grid-template-rows: auto minmax(0, 1fr) auto auto; - gap: 16px; - width: min(880px, 100%); - height: calc(100vh - 48px); + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + width: min(1440px, 100%); + height: calc(100dvh - 24px); + max-height: calc(100vh - 24px); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 20px; box-shadow: var(--shadow); - padding: 18px; + padding: 14px; +} + +.app__body { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(300px, 32vw, 420px); + gap: 12px; + min-height: 0; +} + +.app__main { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 0; + min-height: 0; + overflow: hidden; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); } /* Header ---------------------------------------------------------------- */ .app__header { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 16px; - padding-bottom: 14px; + gap: 12px; + padding-bottom: 10px; border-bottom: 1px solid var(--border); } @@ -119,11 +138,12 @@ body { color: var(--text); border: 1px solid var(--border); border-radius: 10px; - padding: 8px 10px; + padding: 7px 10px; font: inherit; - font-size: 13px; + font-size: 12px; outline: none; - min-width: 240px; + width: 100%; + min-width: 0; } .connection__field input:focus { @@ -168,11 +188,9 @@ body { .chat { overflow: hidden; - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius); display: flex; flex-direction: column; + min-height: 0; } .chat__log { @@ -255,6 +273,9 @@ body { /* WebSocket log --------------------------------------------------------- */ .ws-log { + display: flex; + flex-direction: column; + min-height: 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); @@ -265,9 +286,40 @@ body { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 8px 10px; + gap: 8px; + padding: 8px 12px; border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg-soft); +} + +.ws-log__legend { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: var(--text-dim); +} + +.ws-log__legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ws-log__legend-item::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 2px; +} + +.ws-log__legend-item--send::before { + background: var(--success); +} + +.ws-log__legend-item--recv::before { + background: var(--accent-strong); } .ws-log__header h2 { @@ -278,54 +330,89 @@ body { letter-spacing: 0.8px; } -.ws-log__body { - max-height: 130px; - overflow-y: auto; - padding: 8px 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 11px; - line-height: 1.45; - color: var(--text-dim); +.ws-log__header-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; } -.ws-log__empty { +.ws-log__body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 6px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + line-height: 1.4; color: var(--text-dim); - opacity: 0.7; } .ws-log__entry { display: grid; - grid-template-columns: 72px 44px 1fr; - gap: 8px; + grid-template-columns: 58px 42px minmax(0, 1fr); + gap: 6px; + align-items: start; + padding: 5px 4px; + border-radius: 6px; white-space: pre-wrap; word-break: break-word; } +.ws-log__entry:hover { + background: rgba(255, 255, 255, 0.03); +} + .ws-log__time { color: var(--text-dim); - opacity: 0.75; + opacity: 0.7; + font-size: 10px; } .ws-log__direction { - color: var(--accent-strong); + font-weight: 700; + font-size: 9px; + letter-spacing: 0.4px; text-transform: uppercase; + padding: 2px 0; +} + +.ws-log__detail { + min-width: 0; + overflow-wrap: anywhere; } .ws-log__entry--send .ws-log__direction { color: var(--success); } +.ws-log__entry--recv .ws-log__direction { + color: var(--accent-strong); +} + +.ws-log__entry--system .ws-log__direction { + color: var(--text-dim); +} + .ws-log__entry--error .ws-log__direction { color: var(--danger); } +.ws-log__empty { + color: var(--text-dim); + opacity: 0.7; + padding: 8px 4px; +} + /* Controls -------------------------------------------------------------- */ .controls { display: grid; - gap: 10px; - padding: 14px 16px 6px; + gap: 8px; + padding: 10px 12px 8px; border-top: 1px solid var(--border); + background: var(--bg-elevated); } .meter { @@ -390,14 +477,17 @@ body { .controls__row { display: flex; align-items: center; - gap: 14px; + gap: 10px; + flex-wrap: wrap; } .device-picker { display: flex; flex-direction: column; - gap: 4px; - min-width: 220px; + gap: 3px; + min-width: 0; + flex: 1 1 160px; + max-width: 240px; } .device-picker__label { @@ -413,11 +503,11 @@ body { color: var(--text); border: 1px solid var(--border); border-radius: 10px; - padding: 8px 30px 8px 10px; + padding: 7px 28px 7px 10px; font: inherit; - font-size: 13px; + font-size: 12px; outline: none; - max-width: 300px; + width: 100%; cursor: pointer; } @@ -434,15 +524,17 @@ body { .mic-btn { display: inline-flex; align-items: center; - gap: 8px; - padding: 9px 14px; + gap: 6px; + padding: 8px 12px; border-radius: 999px; background: var(--bg-soft); color: var(--text); border: 1px solid var(--border); font: inherit; + font-size: 12px; font-weight: 600; cursor: pointer; + flex-shrink: 0; transition: transform 0.08s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease; } @@ -549,9 +641,10 @@ body { } .hint { - margin: 4px 2px 0; - font-size: 12px; + margin: 0; + font-size: 11px; color: var(--text-dim); + opacity: 0.85; } .hint kbd { @@ -590,16 +683,32 @@ body { /* Responsive ------------------------------------------------------------ */ +@media (max-width: 820px) { + .app__body { + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) min(240px, 32vh); + } + + .ws-log__legend { + display: none; + } + + .hint { + display: none; + } +} + @media (max-width: 720px) { body { padding: 0; } .app { - height: 100vh; + height: 100dvh; + max-height: 100vh; border-radius: 0; border: none; - padding: 12px; + padding: 10px; } .app__header { @@ -612,24 +721,16 @@ body { } .ws-log__entry { - grid-template-columns: 68px 40px 1fr; + grid-template-columns: 54px 38px minmax(0, 1fr); } .status { justify-content: flex-end; } - .controls__row { - flex-wrap: wrap; - } - .device-picker { - min-width: 100%; - } - - .device-picker__select { + flex: 1 1 100%; max-width: none; - width: 100%; } .indicators {