Update webpage
This commit is contained in:
@@ -6,7 +6,8 @@ A self-contained browser client for the engine's product websocket
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Connect / Disconnect** to any `ws://` or `wss://` URL.
|
- **Connect / Disconnect** to any `ws://` or `wss://` URL.
|
||||||
- **Mic on/off toggle** — getUserMedia is requested with
|
- **Microphone selector + mic on/off toggle** — available input devices
|
||||||
|
are listed with `enumerateDevices`, and getUserMedia is requested with
|
||||||
`echoCancellation`, `noiseSuppression`, and `autoGainControl` so the
|
`echoCancellation`, `noiseSuppression`, and `autoGainControl` so the
|
||||||
browser handles AEC against the bot's voice.
|
browser handles AEC against the bot's voice.
|
||||||
- **Text composer** — type a message and press <kbd>Enter</kbd> to send
|
- **Text composer** — type a message and press <kbd>Enter</kbd> to send
|
||||||
@@ -17,6 +18,8 @@ A self-contained browser client for the engine's product websocket
|
|||||||
(assistant — deltas arrive ahead of the synthesized audio), and locally
|
(assistant — deltas arrive ahead of the synthesized audio), and locally
|
||||||
for text you submit (the engine doesn't echo text input back as a
|
for text you submit (the engine doesn't echo text input back as a
|
||||||
transcript).
|
transcript).
|
||||||
|
- **WebSocket log** panel for connection state and compact send/receive
|
||||||
|
events. Audio chunks are summarized so the UI does not flood.
|
||||||
- **Gapless TTS playback** by scheduling each `response.audio.delta`
|
- **Gapless TTS playback** by scheduling each `response.audio.delta`
|
||||||
chunk back-to-back on the AudioContext.
|
chunk back-to-back on the AudioContext.
|
||||||
- **Live VU meter** + mic and bot activity indicators.
|
- **Live VU meter** + mic and bot activity indicators.
|
||||||
@@ -56,8 +59,9 @@ examples/webpage/
|
|||||||
|
|
||||||
3. Open <http://localhost:8080> in Chrome, Edge, or Safari.
|
3. Open <http://localhost:8080> in Chrome, Edge, or Safari.
|
||||||
- Click **Connect** (uses `ws://127.0.0.1:8001/ws-product` by default).
|
- Click **Connect** (uses `ws://127.0.0.1:8001/ws-product` by default).
|
||||||
- Click **Enable mic** and start speaking. The browser will prompt
|
- Pick a microphone if needed, click **Enable mic**, and start
|
||||||
for microphone access on first use.
|
speaking. The browser will prompt for microphone access on first
|
||||||
|
use. Device names may appear only after permission is granted.
|
||||||
|
|
||||||
> The browser's mic API requires a secure context. `http://localhost`
|
> The browser's mic API requires a secure context. `http://localhost`
|
||||||
> qualifies; if you serve from another host, use HTTPS and a `wss://`
|
> qualifies; if you serve from another host, use HTTPS and a `wss://`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Open/close the websocket and run the session handshake.
|
* - 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
|
* - Downsample to PCM16 mono @ 16 kHz in an AudioWorklet and stream frames
|
||||||
* as binary websocket messages.
|
* as binary websocket messages.
|
||||||
* - Play `response.audio.delta` frames gaplessly through Web Audio.
|
* - Play `response.audio.delta` frames gaplessly through Web Audio.
|
||||||
@@ -15,6 +15,8 @@ const SAMPLE_RATE = 16000;
|
|||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const FRAME_MS = 20;
|
const FRAME_MS = 20;
|
||||||
const PROTOCOL = "va.ws.v1";
|
const PROTOCOL = "va.ws.v1";
|
||||||
|
const MAX_WS_LOG_LINES = 120;
|
||||||
|
const AUDIO_DELTA_LOG_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
url: document.getElementById("ws-url"),
|
url: document.getElementById("ws-url"),
|
||||||
@@ -23,10 +25,13 @@ const els = {
|
|||||||
statusText: document.getElementById("status-text"),
|
statusText: document.getElementById("status-text"),
|
||||||
chatLog: document.getElementById("chat-log"),
|
chatLog: document.getElementById("chat-log"),
|
||||||
micBtn: document.getElementById("mic-btn"),
|
micBtn: document.getElementById("mic-btn"),
|
||||||
|
micSelect: document.getElementById("mic-select"),
|
||||||
micLabel: document.querySelector(".mic-btn__label"),
|
micLabel: document.querySelector(".mic-btn__label"),
|
||||||
micIndicator: document.getElementById("mic-indicator"),
|
micIndicator: document.getElementById("mic-indicator"),
|
||||||
botIndicator: document.getElementById("bot-indicator"),
|
botIndicator: document.getElementById("bot-indicator"),
|
||||||
clearBtn: document.getElementById("clear-btn"),
|
clearBtn: document.getElementById("clear-btn"),
|
||||||
|
clearWsLogBtn: document.getElementById("clear-ws-log-btn"),
|
||||||
|
wsLog: document.getElementById("ws-log"),
|
||||||
meterFill: document.getElementById("meter-fill"),
|
meterFill: document.getElementById("meter-fill"),
|
||||||
composer: document.getElementById("composer"),
|
composer: document.getElementById("composer"),
|
||||||
textInput: document.getElementById("text-input"),
|
textInput: document.getElementById("text-input"),
|
||||||
@@ -44,6 +49,8 @@ const state = {
|
|||||||
recorderNode: null,
|
recorderNode: null,
|
||||||
|
|
||||||
micEnabled: false,
|
micEnabled: false,
|
||||||
|
micDevices: [],
|
||||||
|
selectedMicDeviceId: "",
|
||||||
|
|
||||||
// Output scheduling.
|
// Output scheduling.
|
||||||
nextPlaybackTime: 0,
|
nextPlaybackTime: 0,
|
||||||
@@ -57,6 +64,11 @@ const state = {
|
|||||||
|
|
||||||
// VU meter smoothing.
|
// VU meter smoothing.
|
||||||
meterLevel: 0,
|
meterLevel: 0,
|
||||||
|
|
||||||
|
// Compact websocket logging.
|
||||||
|
audioDeltaLogCount: 0,
|
||||||
|
audioDeltaLogBytes: 0,
|
||||||
|
lastAudioDeltaLogAt: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ UI */
|
/* ------------------------------------------------------------------ UI */
|
||||||
@@ -90,6 +102,10 @@ function setMicButton() {
|
|||||||
els.micIndicator.classList.toggle("is-active", state.micEnabled);
|
els.micIndicator.classList.toggle("is-active", state.micEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMicSelectEnabled() {
|
||||||
|
els.micSelect.disabled = !state.connected || !navigator.mediaDevices;
|
||||||
|
}
|
||||||
|
|
||||||
function setComposerEnabled(enabled) {
|
function setComposerEnabled(enabled) {
|
||||||
els.textInput.disabled = !enabled;
|
els.textInput.disabled = !enabled;
|
||||||
els.sendBtn.disabled = !enabled || els.textInput.value.trim().length === 0;
|
els.sendBtn.disabled = !enabled || els.textInput.value.trim().length === 0;
|
||||||
@@ -139,6 +155,100 @@ function clearChat() {
|
|||||||
els.chatLog.appendChild(empty);
|
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 */
|
/* ---------------------------------------------------------------- Audio */
|
||||||
|
|
||||||
async function ensureAudioContext() {
|
async function ensureAudioContext() {
|
||||||
@@ -153,18 +263,63 @@ async function ensureAudioContext() {
|
|||||||
return state.audioContext;
|
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() {
|
async function startMic() {
|
||||||
const ctx = await ensureAudioContext();
|
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({
|
state.micStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: audioConstraints,
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
channelCount: 1,
|
|
||||||
},
|
|
||||||
video: false,
|
video: false,
|
||||||
});
|
});
|
||||||
|
await refreshMicDevices();
|
||||||
|
|
||||||
state.micSourceNode = ctx.createMediaStreamSource(state.micStream);
|
state.micSourceNode = ctx.createMediaStreamSource(state.micStream);
|
||||||
state.recorderNode = new AudioWorkletNode(ctx, "pcm-recorder", {
|
state.recorderNode = new AudioWorkletNode(ctx, "pcm-recorder", {
|
||||||
@@ -187,10 +342,12 @@ async function startMic() {
|
|||||||
|
|
||||||
state.micSourceNode.connect(state.recorderNode);
|
state.micSourceNode.connect(state.recorderNode);
|
||||||
state.micEnabled = true;
|
state.micEnabled = true;
|
||||||
|
addWsLog("send", "input.audio binary stream started");
|
||||||
setMicButton();
|
setMicButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopMic() {
|
function stopMic() {
|
||||||
|
const wasEnabled = state.micEnabled;
|
||||||
if (state.recorderNode) {
|
if (state.recorderNode) {
|
||||||
try {
|
try {
|
||||||
state.recorderNode.port.onmessage = null;
|
state.recorderNode.port.onmessage = null;
|
||||||
@@ -220,6 +377,9 @@ function stopMic() {
|
|||||||
}
|
}
|
||||||
state.micEnabled = false;
|
state.micEnabled = false;
|
||||||
updateMeter(0);
|
updateMeter(0);
|
||||||
|
if (wasEnabled) {
|
||||||
|
addWsLog("send", "input.audio binary stream stopped");
|
||||||
|
}
|
||||||
setMicButton();
|
setMicButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +470,11 @@ function sendText(text) {
|
|||||||
const value = (text || "").trim();
|
const value = (text || "").trim();
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) 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
|
// 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
|
// 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
|
// `response.text.final(interrupted=true)` for the in-flight assistant
|
||||||
// turn, which finalizes that bubble in place. A brand-new bubble for the
|
// turn, which finalizes that bubble in place. A brand-new bubble for the
|
||||||
// reply will be created when `response.text.started` arrives.
|
// reply will be created when `response.text.started` arrives.
|
||||||
state.ws.send(
|
state.ws.send(JSON.stringify(message));
|
||||||
JSON.stringify({
|
logWsPayload("send", message);
|
||||||
type: "input.text",
|
|
||||||
text: value,
|
|
||||||
interrupt: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
stopPlaybackQueue();
|
stopPlaybackQueue();
|
||||||
addBubble("user", value);
|
addBubble("user", value);
|
||||||
return true;
|
return true;
|
||||||
@@ -420,6 +580,7 @@ async function connect() {
|
|||||||
state.connecting = true;
|
state.connecting = true;
|
||||||
setStatus("connecting", "Connecting…");
|
setStatus("connecting", "Connecting…");
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
|
addWsLog("system", `connecting ${url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pre-warm audio context on user gesture so playback works on Safari.
|
// Pre-warm audio context on user gesture so playback works on Safari.
|
||||||
@@ -429,6 +590,7 @@ async function connect() {
|
|||||||
state.connecting = false;
|
state.connecting = false;
|
||||||
setStatus("error", "Audio init failed");
|
setStatus("error", "Audio init failed");
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
|
addWsLog("error", `audio init failed: ${err.message || err}`, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,30 +602,35 @@ async function connect() {
|
|||||||
state.connecting = false;
|
state.connecting = false;
|
||||||
setStatus("error", "Bad URL");
|
setStatus("error", "Bad URL");
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
|
addWsLog("error", `bad websocket URL: ${err.message || err}`, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
state.ws = ws;
|
state.ws = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
|
const startMessage = {
|
||||||
|
type: "session.start",
|
||||||
|
protocol: PROTOCOL,
|
||||||
|
audio: {
|
||||||
|
encoding: "pcm_s16le",
|
||||||
|
sample_rate: SAMPLE_RATE,
|
||||||
|
channels: CHANNELS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
state.connecting = false;
|
state.connecting = false;
|
||||||
state.connected = true;
|
state.connected = true;
|
||||||
resetPlaybackClock();
|
resetPlaybackClock();
|
||||||
|
addWsLog("system", "websocket open");
|
||||||
setStatus("connected", "Connected");
|
setStatus("connected", "Connected");
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
setMicButton();
|
setMicButton();
|
||||||
|
setMicSelectEnabled();
|
||||||
|
refreshMicDevices();
|
||||||
|
|
||||||
ws.send(
|
ws.send(JSON.stringify(startMessage));
|
||||||
JSON.stringify({
|
logWsPayload("send", startMessage);
|
||||||
type: "session.start",
|
|
||||||
protocol: PROTOCOL,
|
|
||||||
audio: {
|
|
||||||
encoding: "pcm_s16le",
|
|
||||||
sample_rate: SAMPLE_RATE,
|
|
||||||
channels: CHANNELS,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
addBubble("system", "Session started.");
|
addBubble("system", "Session started.");
|
||||||
setComposerEnabled(true);
|
setComposerEnabled(true);
|
||||||
els.textInput.focus();
|
els.textInput.focus();
|
||||||
@@ -477,11 +644,18 @@ async function connect() {
|
|||||||
parsed = JSON.parse(data);
|
parsed = JSON.parse(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Bad JSON from server", err, data);
|
console.warn("Bad JSON from server", err, data);
|
||||||
|
addWsLog(
|
||||||
|
"error",
|
||||||
|
`invalid JSON from server: ${truncateLogValue(data)}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logWsPayload("recv", parsed);
|
||||||
handleEvent(parsed);
|
handleEvent(parsed);
|
||||||
} else if (data instanceof ArrayBuffer) {
|
} else if (data instanceof ArrayBuffer) {
|
||||||
// Server doesn't currently send binary, but handle it just in case.
|
// Server doesn't currently send binary, but handle it just in case.
|
||||||
|
addWsLog("recv", `binary audio ${data.byteLength} bytes`);
|
||||||
schedulePlayback(new Int16Array(data));
|
schedulePlayback(new Int16Array(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -489,6 +663,7 @@ async function connect() {
|
|||||||
ws.addEventListener("error", (err) => {
|
ws.addEventListener("error", (err) => {
|
||||||
console.error("WebSocket error", err);
|
console.error("WebSocket error", err);
|
||||||
setStatus("error", "Connection error");
|
setStatus("error", "Connection error");
|
||||||
|
addWsLog("error", "websocket error", "error");
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
@@ -500,8 +675,16 @@ async function connect() {
|
|||||||
stopPlaybackQueue();
|
stopPlaybackQueue();
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
setMicButton();
|
setMicButton();
|
||||||
|
setMicSelectEnabled();
|
||||||
setComposerEnabled(false);
|
setComposerEnabled(false);
|
||||||
setBotIndicator(false);
|
setBotIndicator(false);
|
||||||
|
flushAudioDeltaLog();
|
||||||
|
addWsLog(
|
||||||
|
"system",
|
||||||
|
`websocket close code=${event.code}${
|
||||||
|
event.reason ? ` reason=${event.reason}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
if (wasConnected) {
|
if (wasConnected) {
|
||||||
addBubble(
|
addBubble(
|
||||||
"system",
|
"system",
|
||||||
@@ -518,9 +701,9 @@ function disconnect() {
|
|||||||
if (!state.ws) return;
|
if (!state.ws) return;
|
||||||
try {
|
try {
|
||||||
if (state.ws.readyState === WebSocket.OPEN) {
|
if (state.ws.readyState === WebSocket.OPEN) {
|
||||||
state.ws.send(
|
const stopMessage = { type: "session.stop", reason: "client_disconnect" };
|
||||||
JSON.stringify({ type: "session.stop", reason: "client_disconnect" }),
|
state.ws.send(JSON.stringify(stopMessage));
|
||||||
);
|
logWsPayload("send", stopMessage);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* ignore */
|
/* 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", () => {
|
els.clearBtn.addEventListener("click", () => {
|
||||||
clearChat();
|
clearChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.clearWsLogBtn.addEventListener("click", () => {
|
||||||
|
clearWsLog();
|
||||||
|
});
|
||||||
|
|
||||||
function autosizeTextarea() {
|
function autosizeTextarea() {
|
||||||
const ta = els.textInput;
|
const ta = els.textInput;
|
||||||
ta.style.height = "auto";
|
ta.style.height = "auto";
|
||||||
@@ -611,4 +820,5 @@ window.addEventListener("beforeunload", () => {
|
|||||||
setStatus("idle", "Disconnected");
|
setStatus("idle", "Disconnected");
|
||||||
setConnectButton();
|
setConnectButton();
|
||||||
setMicButton();
|
setMicButton();
|
||||||
|
setMicSelectEnabled();
|
||||||
setComposerEnabled(false);
|
setComposerEnabled(false);
|
||||||
|
|||||||
@@ -48,6 +48,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="ws-log" aria-label="WebSocket log">
|
||||||
|
<div class="ws-log__header">
|
||||||
|
<h2>WebSocket Log</h2>
|
||||||
|
<button id="clear-ws-log-btn" class="btn btn--ghost" type="button">
|
||||||
|
Clear log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ws-log" class="ws-log__body" role="log" aria-live="polite">
|
||||||
|
<div class="ws-log__empty">No websocket events yet.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer class="controls" aria-label="Chat controls">
|
<footer class="controls" aria-label="Chat controls">
|
||||||
<div class="meter" aria-hidden="true">
|
<div class="meter" aria-hidden="true">
|
||||||
<div id="meter-fill" class="meter__fill"></div>
|
<div id="meter-fill" class="meter__fill"></div>
|
||||||
@@ -73,6 +85,13 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="controls__row">
|
<div class="controls__row">
|
||||||
|
<label class="device-picker">
|
||||||
|
<span class="device-picker__label">Microphone</span>
|
||||||
|
<select id="mic-select" class="device-picker__select" disabled>
|
||||||
|
<option value="">Default microphone</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="mic-btn"
|
id="mic-btn"
|
||||||
class="mic-btn"
|
class="mic-btn"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ body {
|
|||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
width: min(880px, 100%);
|
width: min(880px, 100%);
|
||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 48px);
|
||||||
@@ -252,6 +252,73 @@ body {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WebSocket log --------------------------------------------------------- */
|
||||||
|
|
||||||
|
.ws-log {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
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__empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 44px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__time {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__direction {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__entry--send .ws-log__direction {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log__entry--error .ws-log__direction {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* Controls -------------------------------------------------------------- */
|
/* Controls -------------------------------------------------------------- */
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -326,6 +393,44 @@ body {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker__select {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 30px 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
max-width: 300px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker__select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker__select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.mic-btn {
|
.mic-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -506,10 +611,27 @@ body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-log__entry {
|
||||||
|
grid-template-columns: 68px 40px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls__row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker__select {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.indicators {
|
.indicators {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user