865 lines
33 KiB
JavaScript
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 }); });
|
|
})();
|