Files
vllm-omni/examples/online_serving/realtime_web/app/static/app.js
T

865 lines
33 KiB
JavaScript

(() => {
'use strict';
const config = window.OMNI_REALTIME_CONFIG || window.FULL_DUPLEX_CONFIG || {};
const profile = window.OmniRealtimeProfiles[config.profile || 'minicpm-native'](config);
const callButton = document.getElementById('callButton');
const sendTurnButton = document.getElementById('sendTurnButton');
const muteButton = document.getElementById('muteButton');
const cameraButton = document.getElementById('cameraButton');
const cameraPreview = document.getElementById('cameraPreview');
const promptPreset = document.getElementById('promptPreset');
const systemPromptInput = document.getElementById('systemPrompt');
const connectionState = document.getElementById('connectionState');
const modelState = document.getElementById('modelState');
const playbackState = document.getElementById('playbackState');
const sessionTimer = document.getElementById('sessionTimer');
const meterFill = document.getElementById('meterFill');
const conversation = document.getElementById('conversation');
const emptyConversation = document.getElementById('emptyConversation');
const eventLog = document.getElementById('eventLog');
const eventCount = document.getElementById('eventCount');
const runtimeDetail = document.getElementById('runtimeDetail');
const clearLogButton = document.getElementById('clearLogButton');
const INPUT_RATE = 16000;
const OUTPUT_RATE = 24000;
const SEND_INTERVAL_MS = 200;
const ECHO_GUARD_MS = 300;
const INITIAL_PLAYBACK_BUFFER_MS = 400;
const SESSION_CLOSE_TIMEOUT_MS = 1000;
const PROMPT_PRESETS = profile.presets;
document.title = profile.title;
document.getElementById('pageTitle').textContent = profile.title;
document.getElementById('pageEyebrow').textContent = profile.eyebrow;
document.getElementById('profileDescription').textContent = profile.description;
document.getElementById('policyLabel').textContent = profile.policy;
cameraButton.hidden = !profile.camera;
sendTurnButton.hidden = !profile.clientCommit;
promptPreset.replaceChildren();
for (const name of [...Object.keys(PROMPT_PRESETS), 'custom']) {
const option = document.createElement('option');
option.value = name;
option.textContent = name.replaceAll('_', ' ');
promptPreset.appendChild(option);
}
systemPromptInput.value = Object.values(PROMPT_PRESETS)[0];
document.getElementById('promptControls').hidden = !profile.instructions;
let socket = null;
let closingSocket = null;
let mediaStream = null;
let captureContext = null;
let captureNode = null;
let playbackContext = null;
let playbackNode = null;
let sendTimer = null;
let clockTimer = null;
let startedAt = 0;
let running = false;
let muted = false;
let assistantActive = false;
let captureRate = INPUT_RATE;
let cameraStream = null;
let cameraTimer = null;
let cameraPendingFrame = null;
const cameraCanvas = document.createElement('canvas');
let playbackRate = OUTPUT_RATE;
let pendingCapture = [];
let currentResponseId = null;
let responseHasAudio = false;
let logCount = 0;
let liveUserTurn = null;
let liveAssistantTurn = null;
let sessionCloseResolver = null;
let responseComplete = false;
let playbackComplete = true;
let turnSubmitted = false;
let echoTimer = null;
let sessionGeneration = 0;
let audioChain = Promise.resolve();
// Retain interrupted IDs until startSession() resets the session. Queued
// events and pending audio decodes can outlive response.done, so removing
// IDs there could resume interrupted playback. This set grows with the
// interruptions in a call; sessionGeneration rejects old-session decodes
// after the set is cleared for a new session.
const interruptedResponses = new Set();
const pendingEvents = new Set();
let assistantTextChannel = null;
let connectionReady = false;
let turnCounter = 0;
let stopping = null;
let turnTimeout = null;
function markBusy() {
assistantActive = true;
if (profile.halfDuplex) pendingCapture = [];
}
function armTurnTimeout() {
clearTimeout(turnTimeout);
turnTimeout = window.setTimeout(() => {
failSession('Timed out waiting for the model response. Check the backend event log and restart the session.');
}, 120000);
}
async function failSession(message) {
appendLog(message, true);
await stopSession({ terminal: false });
setConnection('Error', 'error');
runtimeDetail.textContent = message;
}
function finishResponseIfReady() {
if (!responseComplete || !playbackComplete || echoTimer !== null) return;
const generation = sessionGeneration;
echoTimer = window.setTimeout(async () => {
echoTimer = null;
if (!running || generation !== sessionGeneration) return;
if (profile.reconnectEachTurn) {
connectionReady = false;
const previous = socket;
socket = null;
if (previous) { previous.onclose = null; previous.onmessage = null; previous.close(); }
try { await openSocket(); }
catch (error) { await failSession(error.message); return; }
if (!running || generation !== sessionGeneration) return;
}
assistantActive = false;
responseComplete = false;
turnSubmitted = false;
currentResponseId = null;
responseHasAudio = false;
assistantTextChannel = null;
if (profile.halfDuplex) pendingCapture = [];
sendTurnButton.disabled = !profile.clientCommit;
setModel(profile.waiting);
}, ECHO_GUARD_MS);
}
function staticAssetUrl(path) {
const version = String(config.appVersion || '').trim();
return version ? `${path}?v=${encodeURIComponent(version)}` : path;
}
if (promptPreset && systemPromptInput) {
promptPreset.addEventListener('change', () => {
const preset = PROMPT_PRESETS[promptPreset.value];
if (preset !== undefined) systemPromptInput.value = preset;
});
systemPromptInput.addEventListener('input', () => {
promptPreset.value = 'custom';
});
}
function realtimeUrl() {
return profile.url(config, window.location.href);
}
function setConnection(label, kind) {
connectionState.textContent = label;
connectionState.className = `status status-${kind}`;
}
function setModel(label) {
modelState.textContent = label;
}
function setPlayback(label) {
playbackState.textContent = label;
}
function compactEvent(event) {
const fields = [];
const payload = event.event || event;
const responseId = responseIdOf(event);
if (responseId) fields.push(`response=${responseId}`);
if (event.item_id) fields.push(`item=${event.item_id}`);
if (event.code) fields.push(`code=${event.code}`);
if (event.response && event.response.status) fields.push(`status=${event.response.status}`);
if (payload.played_ms !== undefined) fields.push(`played=${payload.played_ms}ms`);
if (payload.committed_ms !== undefined) fields.push(`committed=${payload.committed_ms}ms`);
if (payload.history_committed !== undefined) fields.push(`history=${payload.history_committed}`);
return fields.join(' ');
}
function appendLog(message, error = false) {
const time = new Date().toLocaleTimeString([], { hour12: false });
const line = document.createElement('span');
if (error) line.className = 'log-error';
line.textContent = `${time} ${message}\n`;
eventLog.appendChild(line);
eventLog.scrollTop = eventLog.scrollHeight;
logCount += 1;
eventCount.textContent = `${logCount} ${logCount === 1 ? 'event' : 'events'}`;
}
function appendEventLog(event) {
const detail = compactEvent(event);
appendLog(`${event.type || 'unknown'}${detail ? ` ${detail}` : ''}`, event.type === 'error');
}
function responseIdOf(event) {
return event.response_id || (event.response && event.response.id) || null;
}
function ensureTurn(role) {
const existing = role === 'user' ? liveUserTurn : liveAssistantTurn;
if (existing) return existing;
if (emptyConversation) emptyConversation.remove();
const row = document.createElement('div');
row.className = `turn turn-${role} turn-live`;
const label = document.createElement('div');
label.className = 'turn-role';
label.textContent = role === 'user' ? 'You' : 'Assistant';
const text = document.createElement('div');
text.className = 'turn-text';
row.append(label, text);
conversation.appendChild(row);
conversation.scrollTop = conversation.scrollHeight;
const turn = { row, text, value: '' };
if (role === 'user') liveUserTurn = turn;
else liveAssistantTurn = turn;
return turn;
}
function addTranscript(role, delta) {
if (!delta) return;
const turn = ensureTurn(role);
turn.value += delta;
turn.text.textContent = turn.value;
conversation.scrollTop = conversation.scrollHeight;
}
function finishTranscript(role, finalText = '') {
const turn = role === 'user' ? liveUserTurn : liveAssistantTurn;
if (!turn && !finalText) return;
const current = turn || ensureTurn(role);
if (finalText) {
current.value = finalText;
current.text.textContent = finalText;
}
current.row.classList.remove('turn-live');
if (role === 'user') liveUserTurn = null;
else liveAssistantTurn = null;
}
function bytesToBase64(bytes) {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function int16ToBase64(pcm) {
return bytesToBase64(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
}
function base64ToBytes(encoded) {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function resampleInt16(input, sourceRate, targetRate) {
if (sourceRate === targetRate) return input;
const ratio = sourceRate / targetRate;
const output = new Int16Array(Math.floor(input.length / ratio));
for (let index = 0; index < output.length; index += 1) {
const start = Math.floor(index * ratio);
const end = Math.max(start + 1, Math.min(input.length, Math.floor((index + 1) * ratio)));
let sum = 0;
for (let source = start; source < end; source += 1) sum += input[source];
output[index] = sum / (end - start);
}
return output;
}
async function decodeAudioDelta(event) {
const encoded = event.delta || (event.response && event.response.audio);
if (!encoded) return null;
const bytes = base64ToBytes(encoded);
const format = String(event.format || event.audio_format || 'pcm16').toLowerCase();
const sourceRate = Number(event.sample_rate_hz || event.sample_rate || OUTPUT_RATE);
if (format.includes('f32')) {
const floats = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
const pcm = new Int16Array(floats.length);
for (let index = 0; index < floats.length; index += 1) {
const sample = Math.max(-1, Math.min(1, floats[index]));
pcm[index] = sample < 0 ? sample * 32768 : sample * 32767;
}
return { pcm, sourceRate };
}
if (format.includes('wav')) {
const decoded = await playbackContext.decodeAudioData(bytes.buffer.slice(0));
const channel = decoded.getChannelData(0);
const pcm = new Int16Array(channel.length);
for (let index = 0; index < channel.length; index += 1) {
const sample = Math.max(-1, Math.min(1, channel[index]));
pcm[index] = sample < 0 ? sample * 32768 : sample * 32767;
}
return { pcm, sourceRate: decoded.sampleRate };
}
return {
pcm: new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2)),
sourceRate,
};
}
function updateMeter(pcm) {
let peak = 0;
for (let index = 0; index < pcm.length; index += 8) peak = Math.max(peak, Math.abs(pcm[index]));
meterFill.style.width = `${Math.min(100, (peak / 32768) * 150).toFixed(0)}%`;
}
function microphoneUploadEnabled() {
return running && connectionReady && !muted && (!profile.halfDuplex || !assistantActive);
}
function flushCapture() {
if (!socket || socket.readyState !== WebSocket.OPEN || pendingCapture.length === 0) return;
if (!microphoneUploadEnabled()) {
pendingCapture = [];
return;
}
const length = pendingCapture.reduce((total, chunk) => total + chunk.length, 0);
const merged = new Int16Array(length);
let offset = 0;
for (const chunk of pendingCapture) {
merged.set(chunk, offset);
offset += chunk.length;
}
pendingCapture = [];
const pcm = resampleInt16(merged, captureRate, profile.inputSampleRate || INPUT_RATE);
const appendEvent = profile.append(int16ToBase64(pcm), cameraPendingFrame);
cameraPendingFrame = null;
socket.send(JSON.stringify(appendEvent));
}
function beginAssistant(responseId) {
currentResponseId = responseId || currentResponseId;
responseHasAudio = false;
assistantActive = true;
responseComplete = false;
playbackComplete = true;
if (profile.halfDuplex) pendingCapture = [];
setModel(profile.halfDuplex ? 'Thinking' : 'Speaking');
}
function feedPlayback(decoded, responseId) {
if (!decoded || !decoded.pcm || decoded.pcm.length === 0 || !playbackNode) return;
const pcm = resampleInt16(decoded.pcm, decoded.sourceRate, playbackRate);
responseHasAudio = true;
playbackComplete = false;
assistantActive = true;
setPlayback('Buffering');
playbackNode.port.postMessage({
type: 'audio',
pcm,
responseId: responseId || currentResponseId,
initialBufferMs: INITIAL_PLAYBACK_BUFFER_MS,
}, [pcm.buffer]);
}
function requestPlaybackDrain(responseId) {
if (!responseHasAudio) { playbackComplete = true; finishResponseIfReady(); return; }
if (!playbackNode) return;
playbackNode.port.postMessage({ type: 'drain', responseId: responseId || currentResponseId });
}
function sendPlaybackAck(responseId, playedMs) {
if (!responseId || !socket || socket.readyState !== WebSocket.OPEN || playedMs <= 0) {
if (!responseId && playedMs > 0) appendLog('playback ack skipped: missing response id', true);
return;
}
const ack = profile.ack(responseId, playedMs);
if (ack) socket.send(JSON.stringify(ack));
}
function playbackDrained(message) {
const responseId = message.responseId || currentResponseId;
if (currentResponseId && responseId !== currentResponseId) return;
if (profile.playbackAck) sendPlaybackAck(responseId, Number(message.playedMs) || 0);
setPlayback('Idle');
playbackComplete = true;
if (message.underrunMs > 0) appendLog(`playback underrun ${message.underrunMs} ms`);
if (!profile.halfDuplex && !profile.waitForResponseDone) responseComplete = true;
finishResponseIfReady();
}
async function handleAudioEvent(action) {
markBusy();
currentResponseId = action.responseId || currentResponseId || `turn-${turnCounter}`;
setModel('Speaking');
const generation = sessionGeneration;
const responseId = currentResponseId;
const decoded = await decodeAudioDelta(action.event);
if (generation === sessionGeneration && !interruptedResponses.has(responseId)) feedPlayback(decoded, responseId);
}
function handleTranscriptEvent(action) {
if (action.role === 'assistant') {
// Qwen may emit both text and audio-transcript representations.
// Display one channel per response instead of duplicating the answer.
if (profile.deduplicateTranscript && assistantTextChannel && action.channel !== assistantTextChannel) return;
if (action.text) assistantTextChannel = action.channel || assistantTextChannel;
}
if (action.kind === 'text') addTranscript(action.role, action.text);
else if (profile.deduplicateTranscript && action.role === 'assistant') {
if (action.text) {
const turn = ensureTurn('assistant');
turn.value = action.text;
turn.text.textContent = action.text;
}
} else finishTranscript(action.role, action.text);
}
async function handleEvent(event, action = profile.mapEvent(event)) {
appendEventLog(event);
const responseId = action.responseId || responseIdOf(event);
if (responseId && interruptedResponses.has(responseId) && action.kind !== 'interrupt') return;
switch (action.kind) {
case 'connected':
setConnection('Connected', 'online');
if (!assistantActive) setModel(profile.waiting);
break;
case 'interrupt': {
if (responseId) interruptedResponses.add(responseId);
// A late cancellation belongs to its own response, not the newer one
// currently playing. Speech-start events interrupt the local playback.
if (responseId && responseId !== currentResponseId) break;
if (currentResponseId) interruptedResponses.add(currentResponseId);
sessionGeneration += 1;
audioChain = Promise.resolve();
// Move queued events off the interrupted decode, preserving any newer
// response already received. Its chunks must not be lost with the old one.
const queued = [...pendingEvents];
pendingEvents.clear();
for (const pending of queued) enqueueEvent(pending.event, pending.action, pending.source);
clearTimeout(echoTimer);
echoTimer = null;
// Local playback completes immediately on interruption, including
// when the server already completed generation.
clearTimeout(turnTimeout);
if (playbackNode) playbackNode.port.postMessage({ type: 'clear' });
responseComplete = true;
playbackComplete = true;
responseHasAudio = false;
assistantActive = false;
currentResponseId = null;
assistantTextChannel = null;
finishTranscript('assistant');
setPlayback('Idle');
setModel(profile.waiting);
break;
}
case 'listen':
assistantActive = false;
setModel(profile.waiting);
break;
case 'begin':
if (profile.halfDuplex) armTurnTimeout();
if (echoTimer !== null) { clearTimeout(echoTimer); echoTimer = null; }
beginAssistant(responseId);
break;
case 'audio':
await handleAudioEvent(action);
break;
case 'drain':
requestPlaybackDrain(responseId);
break;
case 'text': case 'text-final':
handleTranscriptEvent(action);
break;
case 'done':
clearTimeout(turnTimeout);
responseComplete = true;
finishTranscript('assistant');
requestPlaybackDrain(responseId);
finishResponseIfReady();
break;
case 'backpressure':
armTurnTimeout();
markBusy();
setModel('Thinking / Speaking');
runtimeDetail.textContent = action.message;
break;
case 'ack':
runtimeDetail.textContent = `Playback committed ${action.committedMs} ms`;
break;
case 'closed':
if (sessionCloseResolver) sessionCloseResolver();
sessionCloseResolver = null;
break;
case 'error':
if (action.fatal) { await failSession(action.message); break; }
// A rejected frame or a refused update costs one operation, not the
// call: report it and keep listening.
appendLog(action.message, true);
runtimeDetail.textContent = action.message;
break;
default: break;
}
}
async function openPlayback() {
playbackContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: OUTPUT_RATE });
playbackRate = playbackContext.sampleRate;
await playbackContext.audioWorklet.addModule(staticAssetUrl('static/playback_worklet.js'));
playbackNode = new AudioWorkletNode(playbackContext, 'fullduplex-pcm-playback');
const currentPlaybackNode = playbackNode;
playbackNode.port.onmessage = (message) => {
if (playbackNode !== currentPlaybackNode) return;
if (message.data.type === 'playback-started') setPlayback('Playing');
else if (message.data.type === 'playback-stopped') {
// The old response may report after the next one has started. ACK its
// own cursor without changing the current response's UI state.
if (profile.playbackAck) sendPlaybackAck(message.data.responseId, Number(message.data.playedMs) || 0);
}
else if (message.data.type === 'playback-drained') playbackDrained(message.data);
else if (message.data.type === 'playback-underrun') {
runtimeDetail.textContent = `Playback underrun ${message.data.underrunMs || 0} ms`;
}
};
playbackNode.connect(playbackContext.destination);
await playbackContext.resume();
}
async function openCapture() {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: { ideal: INPUT_RATE },
},
});
try {
captureContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: INPUT_RATE });
} catch (_error) {
captureContext = new (window.AudioContext || window.webkitAudioContext)();
}
captureRate = captureContext.sampleRate;
await captureContext.audioWorklet.addModule(staticAssetUrl('static/pcm_worklet.js'));
const source = captureContext.createMediaStreamSource(mediaStream);
captureNode = new AudioWorkletNode(captureContext, 'fullduplex-pcm-capture');
captureNode.port.onmessage = (message) => {
const pcm = new Int16Array(message.data);
updateMeter(pcm);
if (microphoneUploadEnabled()) pendingCapture.push(pcm);
};
const silentSink = captureContext.createGain();
silentSink.gain.value = 0;
source.connect(captureNode);
captureNode.connect(silentSink).connect(captureContext.destination);
await captureContext.resume();
}
function enqueueEvent(event, action, source) {
const pending = { event, action, source };
pendingEvents.add(pending);
const generation = sessionGeneration;
audioChain = audioChain.then(() => {
if (!pendingEvents.delete(pending)) return;
if (socket === source || action.kind === 'closed') return handleEvent(event, action);
}).catch((error) => {
if (generation === sessionGeneration) return failSession(`Server event failed: ${error.message}`);
});
}
function openSocket() {
return new Promise((resolve, reject) => {
const url = realtimeUrl();
const current = new WebSocket(url);
socket = current;
connectionReady = false;
turnCounter += 1;
let settled = false;
const timer = window.setTimeout(() => {
if (settled) return;
settled = true;
current.onclose = null;
current.close();
reject(new Error(`Session handshake timed out. ${profile.connectionHint}`));
}, 15000);
const rejectOnce = (message) => {
clearTimeout(timer);
if (!settled) { settled = true; reject(new Error(message)); }
};
current.onopen = () => {
const instructions = systemPromptInput.value.trim();
for (const event of profile.initialMessages(config, instructions)) current.send(JSON.stringify(event));
appendLog(`websocket open ${url}`);
};
current.onmessage = (message) => {
if (typeof message.data !== 'string' || (socket !== current && closingSocket !== current)) return;
let event;
try { event = JSON.parse(message.data); }
catch (error) {
if (socket !== current) return;
const detail = `Invalid server event: ${error.message}`;
if (!settled) rejectOnce(detail);
else void failSession(detail);
return;
}
// Shutdown has detached the active socket. Only its close acknowledgement
// may pass, directly: the event queue can itself be awaiting cleanup.
if (closingSocket === current) {
if (event?.type === 'session.closed' && sessionCloseResolver) sessionCloseResolver();
return;
}
const action = profile.mapEvent(event);
if (action.kind === 'error' && !settled) {
rejectOnce(action.message);
return;
}
if (event.type === profile.readyEvent && !settled) {
clearTimeout(timer);
settled = true;
connectionReady = true;
runtimeDetail.textContent = `${captureRate} Hz capture / ${playbackRate} Hz playback`;
resolve();
}
// Stop the speaker immediately, even while an old WAV is decoding.
// Ordinary completion still waits for its preceding audio chunks.
if (action.kind === 'interrupt') {
void handleEvent(event, action);
return;
}
enqueueEvent(event, action, current);
};
current.onerror = () => rejectOnce(`WebSocket connection failed. ${profile.connectionHint}`);
current.onclose = (event) => {
clearTimeout(timer);
connectionReady = false;
appendLog(`websocket closed code=${event.code}`);
if (!settled) rejectOnce(`Connection closed before session ready. ${profile.connectionHint}`);
else if (running && socket === current) failSession(`Backend disconnected (${event.code}). ${profile.connectionHint}`);
};
});
}
function formatElapsed(seconds) {
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
function startClock() {
startedAt = Date.now();
clockTimer = window.setInterval(() => {
sessionTimer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
}
async function startSession() {
if (running) return;
callButton.disabled = true;
setConnection('Connecting', 'connecting');
runtimeDetail.textContent = 'Requesting microphone access';
try {
sessionGeneration += 1;
audioChain = Promise.resolve();
interruptedResponses.clear();
pendingEvents.clear();
await openPlayback();
await openCapture();
await openSocket();
running = true;
muted = false;
assistantActive = false;
sendTimer = window.setInterval(flushCapture, profile.sendIntervalMs || SEND_INTERVAL_MS);
startClock();
callButton.textContent = 'End session';
callButton.classList.add('is-active');
muteButton.disabled = false;
cameraButton.disabled = !profile.camera;
sendTurnButton.disabled = !profile.clientCommit;
setConnection('Connected', 'online');
setModel(profile.waiting);
appendLog('session started');
} catch (error) {
appendLog(`start failed: ${error.message || error}`, true);
await stopSession({ terminal: false });
setConnection('Error', 'error');
runtimeDetail.textContent = String(error.message || error);
} finally {
callButton.disabled = false;
}
}
async function startCamera() {
if (!profile.camera || cameraStream) return;
cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
cameraPreview.srcObject = cameraStream;
cameraPreview.style.display = '';
await cameraPreview.play().catch(() => {});
// Official omni-duplex cadence: one JPEG (quality 0.7) per ~1 s chunk,
// no client-side resize (the server normalizes at scale_resolution=448).
const captureCameraFrame = () => {
if (!cameraStream || cameraPreview.videoWidth === 0) return;
const scale = profile.cameraMaxDimension
? Math.min(1, profile.cameraMaxDimension / Math.max(cameraPreview.videoWidth, cameraPreview.videoHeight)) : 1;
cameraCanvas.width = Math.max(1, Math.round(cameraPreview.videoWidth * scale));
cameraCanvas.height = Math.max(1, Math.round(cameraPreview.videoHeight * scale));
cameraCanvas.getContext('2d').drawImage(cameraPreview, 0, 0, cameraCanvas.width, cameraCanvas.height);
const frame = cameraCanvas.toDataURL('image/jpeg', 0.7).split(',')[1];
if (profile.imageMessages) {
if (connectionReady && socket?.readyState === WebSocket.OPEN) {
for (const event of profile.imageMessages(frame)) socket.send(JSON.stringify(event));
}
} else cameraPendingFrame = frame;
};
// Do not make the first spoken turn race a one-second timer.
captureCameraFrame();
cameraTimer = window.setInterval(captureCameraFrame, 1000);
cameraButton.textContent = 'Camera off';
cameraButton.classList.add('is-active');
appendLog('camera on (1 fps omni frames)');
}
function stopCamera() {
if (cameraTimer !== null) clearInterval(cameraTimer);
cameraTimer = null;
if (cameraStream) {
for (const track of cameraStream.getTracks()) track.stop();
}
cameraStream = null;
cameraPendingFrame = null;
cameraPreview.srcObject = null;
cameraPreview.style.display = 'none';
cameraButton.textContent = 'Camera';
cameraButton.classList.remove('is-active');
}
cameraButton.addEventListener('click', () => {
if (cameraStream) {
stopCamera();
appendLog('camera off');
return;
}
startCamera().catch((error) => appendLog(`camera failed: ${error.message || error}`, true));
});
function waitForSessionClosed(targetSocket, timeoutMs) {
if (!targetSocket || targetSocket.readyState !== WebSocket.OPEN) return Promise.resolve();
return new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
if (sessionCloseResolver === finish) sessionCloseResolver = null;
resolve();
};
sessionCloseResolver = finish;
const timer = window.setTimeout(finish, timeoutMs);
});
}
function stopSession(options = {}) {
if (stopping) return stopping;
stopping = cleanupSession(options).finally(() => { stopping = null; });
return stopping;
}
async function cleanupSession({ terminal = true } = {}) {
sessionGeneration += 1;
pendingEvents.clear();
connectionReady = false;
clearTimeout(echoTimer);
clearTimeout(turnTimeout);
echoTimer = null;
responseComplete = false;
playbackComplete = true;
turnSubmitted = false;
assistantTextChannel = null;
sendTurnButton.disabled = true;
running = false;
assistantActive = false;
pendingCapture = [];
if (sendTimer !== null) clearInterval(sendTimer);
if (clockTimer !== null) clearInterval(clockTimer);
sendTimer = null;
clockTimer = null;
if (socket) {
closingSocket = socket;
socket = null;
closingSocket.onclose = null;
if (terminal && profile.closeSession && closingSocket.readyState === WebSocket.OPEN) {
const closed = waitForSessionClosed(closingSocket, SESSION_CLOSE_TIMEOUT_MS);
closingSocket.send(JSON.stringify({ type: 'session.close' }));
await closed;
}
closingSocket.close(1000, 'client stop');
closingSocket = null;
}
if (playbackNode) playbackNode.port.postMessage({ type: 'clear' });
if (mediaStream) {
for (const track of mediaStream.getTracks()) track.stop();
}
mediaStream = null;
stopCamera();
cameraButton.disabled = true;
if (captureContext) await captureContext.close().catch(() => {});
if (playbackContext) await playbackContext.close().catch(() => {});
captureContext = null;
captureNode = null;
playbackContext = null;
playbackNode = null;
currentResponseId = null;
responseHasAudio = false;
finishTranscript('user');
finishTranscript('assistant');
meterFill.style.width = '0%';
sessionTimer.textContent = '00:00';
callButton.textContent = 'Start session';
callButton.classList.remove('is-active');
muteButton.textContent = 'Mute';
muteButton.classList.remove('is-active');
muteButton.disabled = true;
setConnection('Offline', 'offline');
setModel('Idle');
setPlayback('Idle');
if (runtimeDetail.textContent.startsWith('Playback committed')) return;
if (!runtimeDetail.textContent.startsWith('start failed')) runtimeDetail.textContent = 'No active connection';
}
function toggleMute() {
if (!running) return;
muted = !muted;
pendingCapture = [];
muteButton.textContent = muted ? 'Unmute' : 'Mute';
muteButton.classList.toggle('is-active', muted);
appendLog(muted ? 'microphone muted' : 'microphone unmuted');
}
callButton.addEventListener('click', () => {
if (running) stopSession();
else startSession();
});
sendTurnButton.addEventListener('click', () => {
if (!running || !connectionReady || !profile.clientCommit || turnSubmitted) return;
flushCapture();
for (const event of profile.commitMessages()) socket.send(JSON.stringify(event));
turnSubmitted = true;
responseComplete = false;
markBusy();
armTurnTimeout();
sendTurnButton.disabled = true;
setModel('Thinking');
appendLog('turn submitted');
});
muteButton.addEventListener('click', toggleMute);
clearLogButton.addEventListener('click', () => {
eventLog.textContent = '';
logCount = 0;
eventCount.textContent = '0 events';
});
window.addEventListener('beforeunload', () => { stopSession({ terminal: false }); });
})();