Update webpage

This commit is contained in:
Xin Wang
2026-05-21 14:11:45 +08:00
parent 53d2f5233d
commit 092f532f51
4 changed files with 387 additions and 32 deletions

View File

@@ -4,7 +4,7 @@
*
* Responsibilities:
* - Open/close the websocket and run the session handshake.
* - Capture mic audio with echoCancellation, noiseSuppression, autoGainControl.
* - List/select microphones and capture mic audio with browser AEC enabled.
* - Downsample to PCM16 mono @ 16 kHz in an AudioWorklet and stream frames
* as binary websocket messages.
* - Play `response.audio.delta` frames gaplessly through Web Audio.
@@ -15,6 +15,8 @@ const SAMPLE_RATE = 16000;
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 els = {
url: document.getElementById("ws-url"),
@@ -23,10 +25,13 @@ const els = {
statusText: document.getElementById("status-text"),
chatLog: document.getElementById("chat-log"),
micBtn: document.getElementById("mic-btn"),
micSelect: document.getElementById("mic-select"),
micLabel: document.querySelector(".mic-btn__label"),
micIndicator: document.getElementById("mic-indicator"),
botIndicator: document.getElementById("bot-indicator"),
clearBtn: document.getElementById("clear-btn"),
clearWsLogBtn: document.getElementById("clear-ws-log-btn"),
wsLog: document.getElementById("ws-log"),
meterFill: document.getElementById("meter-fill"),
composer: document.getElementById("composer"),
textInput: document.getElementById("text-input"),
@@ -44,6 +49,8 @@ const state = {
recorderNode: null,
micEnabled: false,
micDevices: [],
selectedMicDeviceId: "",
// Output scheduling.
nextPlaybackTime: 0,
@@ -57,6 +64,11 @@ const state = {
// VU meter smoothing.
meterLevel: 0,
// Compact websocket logging.
audioDeltaLogCount: 0,
audioDeltaLogBytes: 0,
lastAudioDeltaLogAt: 0,
};
/* ------------------------------------------------------------------ UI */
@@ -90,6 +102,10 @@ function setMicButton() {
els.micIndicator.classList.toggle("is-active", state.micEnabled);
}
function setMicSelectEnabled() {
els.micSelect.disabled = !state.connected || !navigator.mediaDevices;
}
function setComposerEnabled(enabled) {
els.textInput.disabled = !enabled;
els.sendBtn.disabled = !enabled || els.textInput.value.trim().length === 0;
@@ -139,6 +155,100 @@ function clearChat() {
els.chatLog.appendChild(empty);
}
function truncateLogValue(value, maxLength = 160) {
const text = String(value);
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1)}`;
}
function compactWsPayload(payload) {
if (!payload || typeof payload !== "object") return String(payload);
const compact = { ...payload };
if (typeof compact.audio === "string") {
compact.audio = `<base64 ${compact.audio.length} chars>`;
}
if (typeof compact.data === "string" && compact.data.length > 160) {
compact.data = `<string ${compact.data.length} chars>`;
}
if (typeof compact.text === "string") {
compact.text = truncateLogValue(compact.text);
}
try {
return JSON.stringify(compact);
} catch (_) {
return payload.type || "unserializable websocket 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;
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 logWsPayload(direction, payload) {
if (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();
}
return;
}
flushAudioDeltaLog();
addWsLog(direction, compactWsPayload(payload));
}
function clearWsLog() {
state.audioDeltaLogCount = 0;
state.audioDeltaLogBytes = 0;
els.wsLog.innerHTML =
'<div class="ws-log__empty">No websocket events yet.</div>';
}
/* ---------------------------------------------------------------- Audio */
async function ensureAudioContext() {
@@ -153,18 +263,63 @@ async function ensureAudioContext() {
return state.audioContext;
}
function renderMicDevices() {
const previousValue = state.selectedMicDeviceId || els.micSelect.value;
els.micSelect.innerHTML = "";
const defaultOption = document.createElement("option");
defaultOption.value = "";
defaultOption.textContent = "Default microphone";
els.micSelect.appendChild(defaultOption);
state.micDevices.forEach((device, index) => {
const option = document.createElement("option");
option.value = device.deviceId;
option.textContent = device.label || `Microphone ${index + 1}`;
els.micSelect.appendChild(option);
});
const hasPrevious = state.micDevices.some(
(device) => device.deviceId === previousValue,
);
state.selectedMicDeviceId = hasPrevious ? previousValue : "";
els.micSelect.value = state.selectedMicDeviceId;
setMicSelectEnabled();
}
async function refreshMicDevices() {
if (!navigator.mediaDevices?.enumerateDevices) {
setMicSelectEnabled();
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
state.micDevices = devices.filter((device) => device.kind === "audioinput");
renderMicDevices();
} catch (err) {
console.warn("Could not enumerate microphones", err);
setMicSelectEnabled();
}
}
async function startMic() {
const ctx = await ensureAudioContext();
const audioConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
};
if (state.selectedMicDeviceId) {
audioConstraints.deviceId = { exact: state.selectedMicDeviceId };
}
state.micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
audio: audioConstraints,
video: false,
});
await refreshMicDevices();
state.micSourceNode = ctx.createMediaStreamSource(state.micStream);
state.recorderNode = new AudioWorkletNode(ctx, "pcm-recorder", {
@@ -187,10 +342,12 @@ async function startMic() {
state.micSourceNode.connect(state.recorderNode);
state.micEnabled = true;
addWsLog("send", "input.audio binary stream started");
setMicButton();
}
function stopMic() {
const wasEnabled = state.micEnabled;
if (state.recorderNode) {
try {
state.recorderNode.port.onmessage = null;
@@ -220,6 +377,9 @@ function stopMic() {
}
state.micEnabled = false;
updateMeter(0);
if (wasEnabled) {
addWsLog("send", "input.audio binary stream stopped");
}
setMicButton();
}
@@ -310,6 +470,11 @@ function sendText(text) {
const value = (text || "").trim();
if (!value) return false;
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
const message = {
type: "input.text",
text: value,
interrupt: true,
};
// The engine does not echo text input back as a transcript event, so we
// render the user bubble locally. Also interrupt any in-flight bot audio
@@ -318,13 +483,8 @@ 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({
type: "input.text",
text: value,
interrupt: true,
}),
);
state.ws.send(JSON.stringify(message));
logWsPayload("send", message);
stopPlaybackQueue();
addBubble("user", value);
return true;
@@ -420,6 +580,7 @@ async function connect() {
state.connecting = true;
setStatus("connecting", "Connecting…");
setConnectButton();
addWsLog("system", `connecting ${url}`);
try {
// Pre-warm audio context on user gesture so playback works on Safari.
@@ -429,6 +590,7 @@ async function connect() {
state.connecting = false;
setStatus("error", "Audio init failed");
setConnectButton();
addWsLog("error", `audio init failed: ${err.message || err}`, "error");
return;
}
@@ -440,30 +602,35 @@ async function connect() {
state.connecting = false;
setStatus("error", "Bad URL");
setConnectButton();
addWsLog("error", `bad websocket URL: ${err.message || err}`, "error");
return;
}
ws.binaryType = "arraybuffer";
state.ws = ws;
ws.addEventListener("open", () => {
const startMessage = {
type: "session.start",
protocol: PROTOCOL,
audio: {
encoding: "pcm_s16le",
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
},
};
state.connecting = false;
state.connected = true;
resetPlaybackClock();
addWsLog("system", "websocket open");
setStatus("connected", "Connected");
setConnectButton();
setMicButton();
setMicSelectEnabled();
refreshMicDevices();
ws.send(
JSON.stringify({
type: "session.start",
protocol: PROTOCOL,
audio: {
encoding: "pcm_s16le",
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
},
}),
);
ws.send(JSON.stringify(startMessage));
logWsPayload("send", startMessage);
addBubble("system", "Session started.");
setComposerEnabled(true);
els.textInput.focus();
@@ -477,11 +644,18 @@ async function connect() {
parsed = JSON.parse(data);
} catch (err) {
console.warn("Bad JSON from server", err, data);
addWsLog(
"error",
`invalid JSON from server: ${truncateLogValue(data)}`,
"error",
);
return;
}
logWsPayload("recv", parsed);
handleEvent(parsed);
} else if (data instanceof ArrayBuffer) {
// Server doesn't currently send binary, but handle it just in case.
addWsLog("recv", `binary audio ${data.byteLength} bytes`);
schedulePlayback(new Int16Array(data));
}
});
@@ -489,6 +663,7 @@ async function connect() {
ws.addEventListener("error", (err) => {
console.error("WebSocket error", err);
setStatus("error", "Connection error");
addWsLog("error", "websocket error", "error");
});
ws.addEventListener("close", (event) => {
@@ -500,8 +675,16 @@ async function connect() {
stopPlaybackQueue();
setConnectButton();
setMicButton();
setMicSelectEnabled();
setComposerEnabled(false);
setBotIndicator(false);
flushAudioDeltaLog();
addWsLog(
"system",
`websocket close code=${event.code}${
event.reason ? ` reason=${event.reason}` : ""
}`,
);
if (wasConnected) {
addBubble(
"system",
@@ -518,9 +701,9 @@ function disconnect() {
if (!state.ws) return;
try {
if (state.ws.readyState === WebSocket.OPEN) {
state.ws.send(
JSON.stringify({ type: "session.stop", reason: "client_disconnect" }),
);
const stopMessage = { type: "session.stop", reason: "client_disconnect" };
state.ws.send(JSON.stringify(stopMessage));
logWsPayload("send", stopMessage);
}
} catch (_) {
/* ignore */
@@ -556,10 +739,36 @@ els.micBtn.addEventListener("click", async () => {
}
});
els.micSelect.addEventListener("change", async () => {
state.selectedMicDeviceId = els.micSelect.value;
if (!state.micEnabled) return;
els.micSelect.disabled = true;
els.micBtn.disabled = true;
try {
stopMic();
await startMic();
} catch (err) {
console.error("Mic switch error", err);
addBubble("system", `Mic switch error: ${err.message || err}`);
} finally {
setMicButton();
setMicSelectEnabled();
}
});
if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener("devicechange", refreshMicDevices);
}
els.clearBtn.addEventListener("click", () => {
clearChat();
});
els.clearWsLogBtn.addEventListener("click", () => {
clearWsLog();
});
function autosizeTextarea() {
const ta = els.textInput;
ta.style.height = "auto";
@@ -611,4 +820,5 @@ window.addEventListener("beforeunload", () => {
setStatus("idle", "Disconnected");
setConnectButton();
setMicButton();
setMicSelectEnabled();
setComposerEnabled(false);