hf-realtime-voice / ws /s2s-ws-client.js
A-Mahla's picture
A-Mahla HF Staff
Add explicit Join gate, your-turn state, and at-capacity modal
81940c4
Raw
History Blame Contribute Delete
43.6 kB
// @ts-check
/**
* Minimal WebSocket client for the Hugging Face speech-to-speech load balancer.
*
* Two-step handshake (same /session route as the WebRTC client):
*
* 1. POST `<lb_url>/session` -> JSON `{ connect_url: wss://<compute>/v1/realtime?session_token=<JWT>, ... }`
* 2. Open a WebSocket directly on `connect_url` (no rewrite, unlike the WebRTC client).
*
* Once the socket is open we follow the OpenAI Realtime GA WebSocket
* protocol:
*
* - Server pushes `session.created` immediately after upgrade.
* - We send `session.update` (GA schema: `session.audio.{input,output}`,
* `session.output_modalities`, ...).
* - We stream mic audio as PCM16 16 kHz mono base64 chunks via
* `input_audio_buffer.append`.
* - The server pushes `response.output_audio.delta` (PCM16 24 kHz mono
* base64) and transcript deltas.
*
* Audio is handled internally via two AudioWorklet processors so the
* client owns the full mic-in / speaker-out pipeline. The main app only
* sees high-level lifecycle events (`status`, `transcript`, `error`,
* `session`), the same shape as the WebRTC client.
*
* @typedef {"idle" | "creating-session" | "queued" | "your-turn" | "connecting" |
* "connected" | "user-speaking" | "processing" | "ai-speaking" |
* "closed" | "error"
* } WsStatus
*
* @typedef {Object} WsSessionInfo
* @property {string} sessionId
* @property {string} connectUrl
* @property {string} websocketUrl
* @property {string} sessionToken
* @property {number} pendingTimeoutS
* @property {string} [tier] Login tier from the session proxy ("anon"|"free"|"pro").
* @property {boolean} [limited] Whether this session is metered (heartbeat needed).
* @property {number} [heartbeatSec] Suggested heartbeat cadence in seconds.
* @property {number} [remainingSec] Daily budget left after this grant (display).
*
* @typedef {Object} WsClientOptions
* @property {string} [sessionUrl] URL to POST for the session handshake (returns
* `{ connect_url, ... }`). Usually a same-origin proxy like `api/session` so the
* load-balancer address stays server-side. Provide this OR `directUrl`.
* @property {string} [loadBalancerUrl] Load-balancer base URL. Legacy/direct
* alternative to `sessionUrl`: the client POSTs `<lb>/session` itself. Prefer
* `sessionUrl` so the LB address isn't exposed to the browser.
* @property {string} [directUrl] Full WebSocket URL of an s2s realtime endpoint
* (e.g. `ws://localhost:8080/v1/realtime`). When set, the client skips the
* session POST and dials it directly — no load balancer in between.
* @property {string} voice
* @property {string} instructions
* @property {MediaStream} [micStream] Live mic stream. Provide this OR `acquireMic`.
* @property {() => Promise<MediaStream>} [acquireMic] Lazily obtain the mic stream,
* called only once a session is actually granted (after any queue wait). Lets the
* caller prime mic permission up front but not hold the mic 'in use' indicator on
* while waiting in line. Ignored if `micStream` is already set.
* @property {AudioContext} [audioContext] Pre-created (and resumed) context.
* iOS Safari only lets an AudioContext start from within a user gesture, so
* the caller creates/resumes it synchronously on the orb tap and hands it
* here; otherwise it stays suspended (silent) after the mic/session awaits.
* @property {ToolDef[]} [tools] Function tools declared to the backend in the
* initial `session.update`. The model decides when to call them; the caller
* executes and replies via `sendToolOutput` + `requestResponse`.
* @property {NoiseGate} [noiseGate] Client-side noise gate applied to the mic
* before it's sent. Tunable live via `setNoiseGate`.
*
* @typedef {Object} NoiseGate
* @property {boolean} enabled
* @property {number} thresholdDb Open threshold in dBFS (e.g. -45).
*
* @typedef {Object} ToolDef
* @property {"function"} type
* @property {string} name
* @property {string} description
* @property {object} parameters JSON Schema for the call arguments.
*
* @typedef {Object} TranscriptEvent
* @property {"user" | "assistant"} role
* @property {string} text
* @property {boolean} partial
*/
import {
base64FromArrayBuffer,
base64ToBytes,
extractResponseTranscript,
trimTrailingSlash,
} from "./codec.js";
import { OrbVisualiser, VIS_FFT_SIZE } from "./orb-visualizer.js";
/** Build an Error carrying a `code` (and optional extra fields) so callers can
* branch on the failure kind: "limit" | "queue-full" | "queue-expired" | "aborted".
* @param {string} message @param {string} code @param {object} [extra] */
function _codedError(message, code, extra) {
const err = /** @type {Error & { code?: string }} */ (new Error(message));
err.code = code;
if (extra) Object.assign(err, extra);
return err;
}
// The s2s pipeline runs internally at 16 kHz mono PCM. The WebRTC transport
// resamples to 48 kHz for Opus, but the WebSocket transport emits the
// native pipeline rate. We don't (can't) override it via `audio.output.format`
// because the server's pydantic validator rejects the whole `session.update`
// as soon as a sub-field shape it doesn't know about appears.
const OUTPUT_SAMPLE_RATE = 16000;
const MIC_CHUNK_MS = 40;
export class S2sWsRealtimeClient extends EventTarget {
/** @param {WsClientOptions} options */
constructor(options) {
super();
/** @type {WsClientOptions} */
this.options = options;
/** @type {ToolDef[]} Function tools declared to the backend. */
this._tools = options.tools ?? [];
/** @type {string} Direct realtime WS URL (set => skip the LB session POST). */
this._directUrl = options.directUrl ?? "";
/** @type {string} Where to POST for the session handshake. Prefer the
* explicit `sessionUrl`; fall back to `<loadBalancerUrl>/session` for callers
* that still pass the LB address directly. */
this._sessionUrl = options.sessionUrl
? options.sessionUrl
: options.loadBalancerUrl
? `${trimTrailingSlash(options.loadBalancerUrl)}/session`
: "";
/** @type {(() => Promise<MediaStream>) | null} Lazy mic acquisition (post-grant). */
this._acquireMic = options.acquireMic ?? null;
/** @type {boolean} Set by close() to abort a queue wait in progress. */
this._closed = false;
/** @type {string} The active queue ticket id while waiting (else ""). */
this._queueId = "";
/** @type {(() => void) | null} Wakes the queue poll sleep early on close(). */
this._queueWake = null;
/** @type {ReturnType<typeof setTimeout> | 0} */
this._queueTimer = 0;
// Join gate: after waiting in line the caller must explicitly `join()` before
// we dial, so a slot isn't spent on someone who walked away. Resolved by
// join(), rejected on timeout (the LB reclaims the slot) or close().
/** @type {(() => void) | null} */
this._joinResolve = null;
/** @type {((err: Error) => void) | null} */
this._joinReject = null;
/** @type {ReturnType<typeof setTimeout> | 0} */
this._joinTimer = 0;
/** @type {NoiseGate} Mic noise gate; off by default. */
this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
/** @type {WebSocket | null} */
this._ws = null;
/** @type {AudioContext | null} */
this._ctx = null;
/** @type {MediaStreamAudioSourceNode | null} */
this._micSrc = null;
/** @type {AudioWorkletNode | null} */
this._captureNode = null;
/** @type {AudioWorkletNode | null} */
this._playbackNode = null;
/** @type {GainNode | null} */
this._captureSink = null;
/** @type {AnalyserNode | null} */
this._micAnalyser = null;
/** @type {AnalyserNode | null} */
this._outAnalyser = null;
/** @type {OrbVisualiser | null} */
this._visualiser = null;
/** @type {WsStatus} */
this._status = "idle";
this._aiSpeaking = false;
/** @type {Set<string>} response_ids that have actually played audio, so the
* UI can tell a barge-in cut (keep it) from a never-heard speculative
* response (drop it). */
this._audibleResponses = new Set();
/** @type {Map<string, string>} The CURRENT assistant transcript segment per
* response, accumulated from streamed deltas (reset on each segment's done). */
this._asstTranscriptByResp = new Map();
/** @type {Map<string, string>} Completed assistant transcript segments per
* response, space-joined. A single response can emit several
* `*.transcript.done` events; we concatenate them until response.done. */
this._asstFullByResp = new Map();
this._muted = false;
// ── Response lock ────────────────────────────────────────────────────
// The backend allows only ONE response in flight: creating a second while
// one is active fails with `conversation_already_has_active_response`. So
// we serialize response.create. `_openResponses` counts responses the
// server has confirmed (response.created) but not yet finished
// (response.done) — it's cumulative, so every create maps to one done.
// `_createInFlight` covers the window after we send a create but before its
// response.created echo. Any requestResponse() made while locked is queued
// and replayed, one at a time, as each response.done frees the slot.
this._openResponses = 0;
this._createInFlight = false;
/** @type {{ image?: string }[]} Pending response.create payloads, one per
* queued requestResponse(). A payload may carry an image to send just
* before its create (so the frame travels with the create, not eagerly). */
this._createQueue = [];
/** @type {Promise<void> | null} */
this._readyPromise = null;
this._sessionConfigured = false;
this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })();
}
get status() {
return this._status;
}
/** @param {WsStatus} status */
_setStatus(status) {
if (this._status === status) return;
this._status = status;
this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
}
/** Full assistant transcript so far for a response: the completed segments
* plus the in-progress one, all space-joined.
* @param {string} rid @returns {string} */
_asstDisplay(rid) {
const full = this._asstFullByResp.get(rid) || "";
const seg = this._asstTranscriptByResp.get(rid) || "";
if (!seg) return full;
return full ? `${full} ${seg}` : seg;
}
_markAudible() {
if (this._status === "ai-speaking") return;
if (this._status === "closed" || this._status === "error") return;
this._setStatus("ai-speaking");
}
/**
* Full handshake. Resolves once the WS is open AND the audio pipeline is
* ready to send/receive samples.
* @returns {Promise<void>}
*/
async connect() {
if (this._ws) throw new Error("Already connected");
let connectUrl;
if (this._directUrl) {
// Direct mode: no load balancer, no /session POST — dial the realtime
// endpoint straight away (e.g. a local s2s server).
connectUrl = this._directUrl;
this._setStatus("connecting");
} else {
if (!this._sessionUrl) {
throw new Error("No session endpoint or direct URL configured");
}
this._setStatus("creating-session");
const { grant, waited } = await this._createSessionOrQueue();
if (this._closed) throw _codedError("connect aborted", "aborted");
// If we waited in line, don't dial until the user explicitly joins — this
// keeps a freed slot from being spent on someone who stepped away, and the
// click is a fresh gesture (re-arms the AudioContext on iOS).
if (waited) {
await this._awaitJoin(grant);
if (this._closed) throw _codedError("connect aborted", "aborted");
}
this.dispatchEvent(new CustomEvent("session", { detail: { info: grant } }));
connectUrl = grant.connectUrl;
this._setStatus("connecting");
}
// Acquire the mic now — only once a slot is actually ours. The caller primed
// permission up front, so this is silent and the 'in use' indicator lights
// only for a real, connecting session (never during a queue wait).
if (!this.options.micStream && this._acquireMic) {
this.options.micStream = await this._acquireMic();
}
// Spin up the AudioContext + worklets in parallel with the WS dial.
const audioReady = this._setupAudio();
const wsReady = this._openWebSocket(connectUrl);
await Promise.all([audioReady, wsReady]);
}
/**
* POST the session handshake; if the pool is busy, wait in the queue (polling
* position) until a slot is claimed. Resolves to a grant plus whether we had to
* wait (which decides if an explicit join is required before dialing).
* @returns {Promise<{ grant: WsSessionInfo, waited: boolean }>}
*/
async _createSessionOrQueue() {
const first = await this._postSession();
if (first.state === "queued") {
this._setStatus("queued");
const grant = await this._pollQueue(first);
return { grant, waited: true };
}
return { grant: first.grant, waited: false };
}
/**
* Hold at the front of the line until the user clicks join (resolves the gate)
* or the grant lapses. Announces "your-turn" + a deadline the UI counts down.
* @param {WsSessionInfo} grant
* @returns {Promise<void>}
*/
_awaitJoin(grant) {
// The LB reclaims an unclaimed slot at its pending timeout; expire the gate a
// touch earlier so we never dial a session the LB just reaped.
const windowS = Math.max(3, (grant.pendingTimeoutS || 60) - 3);
this._setStatus("your-turn");
this.dispatchEvent(
new CustomEvent("ready-to-join", { detail: { info: grant, expiresSec: windowS } }),
);
return new Promise((resolve, reject) => {
this._joinResolve = resolve;
this._joinReject = reject;
this._joinTimer = setTimeout(() => {
this._joinResolve = null;
this._joinReject = null;
reject(_codedError("Your spot expired", "join-expired"));
}, windowS * 1000);
});
}
/** Accept the held slot and let connect() proceed to dial. Called from the
* "Join now" click, so it's a user gesture: re-resume the AudioContext, which
* iOS may have suspended while we waited. */
join() {
if (this._joinTimer) {
clearTimeout(this._joinTimer);
this._joinTimer = 0;
}
try {
void this.options.audioContext?.resume();
} catch {
// best-effort; _setupAudio resumes again
}
const resolve = this._joinResolve;
this._joinResolve = null;
this._joinReject = null;
resolve?.();
}
/**
* POST /session once. Returns either a granted session or a queue ticket.
* @returns {Promise<{ state: "granted", grant: WsSessionInfo } | { state: "queued", queueId: string, position: number, pollIntervalS: number }>}
*/
async _postSession() {
const url = this._sessionUrl;
console.log("[ws] POST", url);
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (response.status === 402) {
// The session proxy refused: today's per-tier time budget is spent. Surface
// it as a typed error so the UI shows the limit modal, not a crash.
const body = await response.json().catch(() => ({}));
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 503) {
const body = await response.json().catch(() => ({}));
if (body?.state === "at_capacity") {
throw _codedError("The queue is full — try again shortly.", "queue-full");
}
throw new Error("/session failed (503)");
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`/session failed (${response.status}): ${text}`);
}
const json = await response.json();
if (json.state === "queued") {
return {
state: "queued",
queueId: json.queue_id,
position: json.position,
pollIntervalS: json.poll_interval_s,
};
}
return { state: "granted", grant: this._parseGrant(json) };
}
/**
* Poll the waiting queue until this ticket claims a slot. Emits `queue` events
* ({ position }) as the line advances. Throws on limit (402), expiry (404), or
* close(). Transient network/5xx blips are ignored and retried next tick.
* @param {{ queueId: string, position: number, pollIntervalS: number }} ticket
* @returns {Promise<WsSessionInfo>}
*/
async _pollQueue(ticket) {
const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
this._queueId = ticket.queueId;
this._emitQueue(ticket.position);
while (true) {
await this._queueSleep(intervalMs);
if (this._closed) throw _codedError("queue wait aborted", "aborted");
let response;
try {
response = await fetch(`api/queue/${encodeURIComponent(this._queueId)}`, {
headers: { "Content-Type": "application/json" },
});
} catch {
continue; // network blip — keep our place, retry next tick
}
if (response.status === 402) {
const body = await response.json().catch(() => ({}));
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 404) {
throw _codedError("Queue timed out", "queue-expired");
}
if (!response.ok) continue; // 502/503 — transient, retry
const json = await response.json().catch(() => null);
if (!json) continue;
if (json.state === "queued") {
this._emitQueue(json.position);
continue;
}
// Reached the front and claimed a slot.
this._queueId = "";
return this._parseGrant(json);
}
}
/** @param {number} position */
_emitQueue(position) {
this.dispatchEvent(
new CustomEvent("queue", { detail: { position, queueId: this._queueId } }),
);
}
/** A sleep that close() can cut short so a queued client tears down promptly.
* @param {number} ms */
_queueSleep(ms) {
return new Promise((resolve) => {
this._queueWake = resolve;
this._queueTimer = setTimeout(() => {
this._queueWake = null;
resolve();
}, ms);
});
}
/** @param {any} json @returns {WsSessionInfo} */
_parseGrant(json) {
return {
sessionId: json.session_id,
connectUrl: json.connect_url,
websocketUrl: json.websocket_url,
sessionToken: json.session_token,
pendingTimeoutS: json.pending_timeout_s,
tier: json.tier,
limited: json.limited,
heartbeatSec: json.heartbeatSec,
remainingSec: json.remainingSec,
};
}
async _setupAudio() {
// Prefer a context the caller already created + resumed inside the tap
// gesture (required on iOS). Fall back to creating one here for callers
// that don't (desktop is lenient about the gesture timing).
// Most desktops give us 48 kHz, mobiles can give 44.1/24/16 kHz; the
// capture worklet handles any rate (linear interp fallback).
const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" });
this._ctx = ctx;
// Resume if still suspended. This is best-effort here — on iOS the resume
// that actually counts is the one the caller did synchronously on tap.
if (ctx.state === "suspended") {
try {
await ctx.resume();
} catch (err) {
console.warn("[ws] AudioContext resume failed:", err);
}
}
// The worklets live at the repo root, one level up from this module.
const base = new URL("../worklets/", import.meta.url);
await ctx.audioWorklet.addModule(new URL("mic-capture.js", base).href);
await ctx.audioWorklet.addModule(new URL("audio-playback.js", base).href);
const captureNode = new AudioWorkletNode(ctx, "mic-capture", {
numberOfInputs: 1,
numberOfOutputs: 0,
processorOptions: { chunkMs: MIC_CHUNK_MS },
});
captureNode.port.onmessage = (e) => {
const data = e.data;
if (data instanceof ArrayBuffer) {
this._onMicChunk(data);
} else if (data?.kind === "level") {
// Raw pre-gate mic RMS for the Settings meter.
this.dispatchEvent(new CustomEvent("input-level", { detail: { rms: data.rms } }));
}
};
// Push the initial gate config now that the worklet exists.
captureNode.port.postMessage({ kind: "gate", ...this._noiseGate });
this._captureNode = captureNode;
const micSrc = ctx.createMediaStreamSource(this.options.micStream);
micSrc.connect(captureNode);
this._micSrc = micSrc;
// Mic analyser: tap the mic in parallel with the worklet so we get the
// raw (un-resampled, un-clipped) signal for the visualiser.
const micAnalyser = ctx.createAnalyser();
micAnalyser.fftSize = VIS_FFT_SIZE;
micAnalyser.smoothingTimeConstant = 0;
micSrc.connect(micAnalyser);
this._micAnalyser = micAnalyser;
const playbackNode = new AudioWorkletNode(ctx, "audio-playback", {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [1],
});
playbackNode.port.postMessage({ kind: "config", inputRate: OUTPUT_SAMPLE_RATE });
playbackNode.port.onmessage = (e) => this._onPlaybackMessage(e.data);
// Output analyser sits between the playback worklet and the speakers.
const outAnalyser = ctx.createAnalyser();
outAnalyser.fftSize = VIS_FFT_SIZE;
outAnalyser.smoothingTimeConstant = 0.3;
playbackNode.connect(outAnalyser);
outAnalyser.connect(ctx.destination);
this._outAnalyser = outAnalyser;
this._playbackNode = playbackNode;
this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
this._visualiser.start();
}
/** @param {string} connectUrl */
_openWebSocket(connectUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(connectUrl);
ws.binaryType = "arraybuffer";
this._ws = ws;
const onceOpen = () => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
resolve();
};
const onceErr = (e) => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
};
ws.addEventListener("open", onceOpen);
ws.addEventListener("error", onceErr);
ws.addEventListener("message", (e) => this._onWsMessage(e.data));
ws.addEventListener("close", (e) => this._onWsClose(e));
ws.addEventListener("error", (e) => {
console.error("[ws] socket error", e);
});
});
}
/**
* @param {{ kind: string; queuedMs?: number; played?: number }} data
*/
_onPlaybackMessage(data) {
if (data?.kind === "underrun") {
// Server stopped sending audio mid-response. Most likely the turn
// ended cleanly (a response.done usually arrives just before/after
// this). We let the state machine fall back to "connected" via the
// response.done event handler.
}
}
/**
* Mic worklet just sent us a ~40 ms PCM16 16 kHz mono chunk.
* Base64-encode and forward via the WS.
* @param {ArrayBuffer} pcm16Buffer
*/
_onMicChunk(pcm16Buffer) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
if (!this._sessionConfigured) return; // Server rejects audio before session.update.
if (this._muted) return;
const b64 = base64FromArrayBuffer(pcm16Buffer);
this._send({ type: "input_audio_buffer.append", audio: b64 });
}
/**
* @param {string | ArrayBuffer | Blob} raw
*/
async _onWsMessage(raw) {
let text;
if (typeof raw === "string") {
text = raw;
} else if (raw instanceof ArrayBuffer) {
text = new TextDecoder("utf-8").decode(raw);
} else if (raw instanceof Blob) {
text = await raw.text();
} else {
return;
}
let event;
try {
event = JSON.parse(text);
} catch {
return;
}
const type = event?.type;
if (typeof type !== "string") return;
// Opt-in event tracing for diagnosing turn/transcript issues. Enable with
// `localStorage.setItem("s2s.debug", "1")` in the browser console.
if (this._debug) {
const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
: type.startsWith("response.")
? ` resp=${event.response_id ?? event.response?.id ?? ""} status=${event.response?.status ?? ""} ${event.transcript ?? ""}`
: "";
console.debug(`[ws] ${type}${extra}`);
}
switch (type) {
case "session.created":
// Server-side defaults for the s2s pipeline are already what we
// want (server_vad, whisper-1 transcription, PCM16 16k in / 24k
// out). We only push the user-tunable bits: voice + instructions.
this._sendSessionUpdate();
this._sessionConfigured = true;
if (this._status === "connecting") this._setStatus("connected");
break;
case "session.updated":
// Acknowledged by server, nothing to do.
break;
case "input_audio_buffer.speech_started":
// User started speaking — stop any audio still playing OR queued, every
// time. We clear unconditionally (not just when `_aiSpeaking`): after a
// reply or a tool result the worklet's ring buffer can still be draining
// even though we already flipped `_aiSpeaking` off, and that tail would
// otherwise keep playing over the user's barge-in.
this._playbackNode?.port.postMessage({ kind: "clear" });
this._aiSpeaking = false;
this._setStatus("user-speaking");
break;
case "input_audio_buffer.speech_stopped":
if (this._status === "user-speaking") this._setStatus("processing");
break;
case "response.created":
// A response now owns the slot — count it and clear our create guard
// (this confirms either our create or a server-initiated one).
this._openResponses++;
this._createInFlight = false;
if (this._status === "connected" || this._status === "user-speaking") {
this._setStatus("processing");
}
break;
case "response.output_item.added":
if (this._status === "connected" || this._status === "user-speaking") {
this._setStatus("processing");
}
break;
case "response.audio.delta":
case "response.output_audio.delta": {
this._pushAudioDelta(event.delta);
const rid = event.response_id ?? event.response?.id;
if (rid) this._audibleResponses.add(rid);
if (!this._aiSpeaking) {
this._aiSpeaking = true;
this._markAudible();
}
break;
}
case "response.content_part.added": {
const part = event.part;
if (part?.type === "audio" || part?.type === "output_audio") {
this._markAudible();
}
break;
}
case "response.done": {
this._aiSpeaking = false;
// This response freed the slot (completion OR cancellation both arrive
// as response.done). Decrement and, if a create was waiting, replay it.
this._openResponses = Math.max(0, this._openResponses - 1);
if (this._status === "ai-speaking" || this._status === "processing") {
this._setStatus("connected");
}
// A response closes here for BOTH normal completion and cancellation
// (the s2s server signals a speculative-turn interrupt as
// `response.done` with status "cancelled" — there is no separate
// `response.cancelled` event). Surface the id + status so the UI can
// drop a cancelled response's transcript and commit a completed one.
const status = event.response?.status ?? "completed";
const responseId = event.response?.id ?? "";
// Did this response ever play audio? Distinguishes a barge-in cut (the
// user heard part of it) from a speculative response that never played.
const audible = responseId ? this._audibleResponses.has(responseId) : false;
this._audibleResponses.delete(responseId);
// Pull whatever transcript the response carries, falling back to the
// segments we concatenated from the `*.transcript.done` events (plus any
// in-progress delta). For an interrupted reply the response payload may
// be empty, so this is the last chance to capture the text.
const transcript =
extractResponseTranscript(event.response) ||
this._asstDisplay(responseId) ||
"";
// Response finished — clear both transcript accumulators for it.
this._asstTranscriptByResp.delete(responseId);
this._asstFullByResp.delete(responseId);
this.dispatchEvent(new CustomEvent("response-finished", {
detail: { responseId, status, audible, transcript },
}));
// The slot is free now — replay a queued create (e.g. a tool follow-up
// that arrived while this response was still running).
this._flushQueuedCreate();
break;
}
case "response.function_call_arguments.done": {
const name = typeof event.name === "string" ? event.name : "";
const args = typeof event.arguments === "string" ? event.arguments : "{}";
const callId = typeof event.call_id === "string" ? event.call_id : "";
if (name) {
this.dispatchEvent(new CustomEvent("toolcall", {
detail: { name, arguments: args, callId },
}));
} else {
// A nameless call can't be executed, so no function_call_output is
// ever sent and the model would wait forever for a result. The
// backend shouldn't emit these; warn loudly rather than stall silently.
console.warn(`[ws] function_call_arguments.done with no name (call_id=${callId}); cannot run tool — turn may stall`);
}
break;
}
case "conversation.item.input_audio_transcription.delta": {
const delta = typeof event.delta === "string" ? event.delta : "";
if (delta) {
// `itemId` is REUSED across a speculative continuation, so the UI
// groups both segments into one message. The delta carries the full
// cumulative transcript so far (not an increment).
this.dispatchEvent(
new CustomEvent("transcript", {
detail: {
role: "user",
text: delta,
partial: true,
itemId: typeof event.item_id === "string" ? event.item_id : "",
},
}),
);
}
break;
}
case "conversation.item.input_audio_transcription.completed": {
const transcript = typeof event.transcript === "string" ? event.transcript : "";
if (transcript) {
this.dispatchEvent(
new CustomEvent("transcript", {
detail: {
role: "user",
text: transcript,
partial: false,
itemId: typeof event.item_id === "string" ? event.item_id : "",
},
}),
);
}
break;
}
case "response.audio_transcript.delta":
case "response.output_audio_transcript.delta": {
// Stream the assistant transcript live: accumulate the incremental
// deltas and push the running text to the UI. Every transcribe event we
// receive reaches the conversation, so an interrupted reply already has
// its partial text even if the `.done` never fires.
this._markAudible();
const rid = typeof event.response_id === "string" ? event.response_id : "";
const delta = typeof event.delta === "string" ? event.delta : "";
if (delta) {
this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
// Show completed segments + the segment streaming in right now.
this.dispatchEvent(
new CustomEvent("transcript", {
detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
}),
);
}
break;
}
case "response.audio_transcript.done":
case "response.output_audio_transcript.done": {
const rid = typeof event.response_id === "string" ? event.response_id : "";
// This is ONE completed segment. A response can emit several; concatenate
// them, space-separated, until response.done clears the accumulator.
const segment =
(typeof event.transcript === "string" && event.transcript) ||
this._asstTranscriptByResp.get(rid) ||
"";
this._asstTranscriptByResp.delete(rid); // segment finished; next one starts fresh
if (segment) {
const prev = this._asstFullByResp.get(rid) || "";
this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
}
const full = this._asstFullByResp.get(rid) || "";
if (full) {
this.dispatchEvent(
new CustomEvent("transcript", {
detail: { role: "assistant", text: full, partial: false, responseId: rid },
}),
);
}
break;
}
case "error": {
const err = event.error;
console.error("[ws] server error:", err);
// The "another response is already active" race: our optimistic create
// collided with a still-running response. Don't surface it — clear the
// in-flight guard and re-queue, so the create replays on the next
// response.done (never retried immediately, which would just collide
// again).
if (err?.type === "conversation_already_has_active_response" ||
err?.code === "conversation_already_has_active_response") {
if (this._createInFlight) {
this._createInFlight = false;
// Re-queue a BARE create: any image on the original payload was
// already sent before this (rejected) create, so don't resend it.
this._createQueue.push({});
}
break;
}
// Every other server error is non-fatal: surface it for logging but
// NEVER tear the socket down. Only transport failures (close / failed
// open) are fatal, and those come through their own paths.
this.dispatchEvent(
new CustomEvent("server-error", { detail: { error: new Error(err?.message ?? "Server error") } }),
);
break;
}
}
}
/** @param {string} b64 */
_pushAudioDelta(b64) {
if (!this._playbackNode) return;
if (!b64) return;
const bytes = base64ToBytes(b64);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const samples = new Float32Array(bytes.byteLength / 2);
for (let i = 0; i < samples.length; i++) {
const s = view.getInt16(i * 2, true);
samples[i] = s < 0 ? s / 0x8000 : s / 0x7fff;
}
this._playbackNode.port.postMessage({ kind: "audio", samples }, [samples.buffer]);
}
/** @param {CloseEvent} ev */
_onWsClose(ev) {
console.log("[ws] socket closed:", ev.code, ev.reason);
if (this._status === "closed" || this._status === "error") return;
if (ev.code === 1000) {
this._setStatus("closed");
} else {
this.dispatchEvent(
new CustomEvent("error", {
detail: { error: new Error(`WebSocket closed (${ev.code}) ${ev.reason || ""}`.trim()) },
}),
);
this._setStatus("error");
}
}
_sendSessionUpdate() {
// Minimal payload: only the bits the user is allowed to configure.
// The s2s server already defaults to server_vad, whisper-1
// transcription, 16 kHz PCM input and 24 kHz PCM output, so we don't
// need (and must not send) `audio.input.format`, `audio.input.transcription`,
// `audio.input.turn_detection` or `audio.output.format`: the pydantic
// validator on the server rejects the whole event if any unknown or
// future-shaped sub-field shows up.
/** @type {Record<string, any>} */
const session = {
type: "realtime",
instructions: this.options.instructions,
audio: {
output: { voice: this.options.voice },
},
};
// Tools are declared here; the backend already accepts them in
// session.update and emits response.function_call_arguments.done when the
// model decides to call one. Only include the keys when we actually have
// tools — the server's pydantic validator is strict about shapes.
if (this._tools.length) {
session.tools = this._tools;
session.tool_choice = "auto";
}
this._send({ type: "session.update", session });
}
/** Update voice/instructions on a live session without tearing down. */
/** @param {{ voice?: string; instructions?: string }} patch */
updateSession(patch) {
/** @type {Record<string, any>} */
const session = { type: "realtime" };
if (patch.instructions) session.instructions = patch.instructions;
if (patch.voice) session.audio = { output: { voice: patch.voice } };
if (Object.keys(session).length > 1) {
this._send({ type: "session.update", session });
}
}
/**
* Replace the declared tool set on a live session (e.g. the user flipped a
* tool switch mid-conversation). Always sends `tools` — an empty array
* clears them — so toggling the last tool off actually removes it.
* @param {ToolDef[]} tools
*/
setTools(tools) {
this._tools = tools;
this._send({
type: "session.update",
session: { type: "realtime", tools, tool_choice: tools.length ? "auto" : "none" },
});
}
/**
* Return a tool's result to the model. Pairs with the `toolcall` event's
* `callId`. Caller follows this with `requestResponse()` so the model speaks.
* @param {string} callId
* @param {string} output Plain text / JSON string the model will read.
*/
sendToolOutput(callId, output) {
if (!callId) return; // Can't target a result without the call id.
this._send({
type: "conversation.item.create",
item: { type: "function_call_output", call_id: callId, output },
});
}
/**
* Add an image to the conversation as user content, so the vision-language
* model can see it (used by the camera tool). `dataUrl` is a
* `data:image/jpeg;base64,...` string.
* @param {string} dataUrl
*/
sendUserImage(dataUrl) {
this._send({
type: "conversation.item.create",
item: {
type: "message",
role: "user",
content: [{ type: "input_image", image_url: dataUrl }],
},
});
}
/**
* Ask the model to generate a response now (after feeding tool results).
* Serialized: if a response is already in flight we queue this request and
* replay it once the active response finishes, so we never trip the
* backend's `conversation_already_has_active_response` guard.
*
* @param {{ image?: string }} [opts] Optional `image` (a data URL) sent as a
* user `input_image` immediately before this response.create — so the frame
* travels with the create (and is deferred together with it if queued),
* rather than being added to the conversation eagerly. Used by the camera
* tool so the model sees the snapshot in the response it's about to speak.
*/
requestResponse(opts = {}) {
if (this._responseActive()) {
this._createQueue.push(opts);
if (this._debug) console.debug(`[ws] response.create queued (a response is active); pending=${this._createQueue.length}`);
return;
}
this._createResponseNow(opts);
}
/** True while a response occupies the single backend slot. */
_responseActive() {
return this._openResponses > 0 || this._createInFlight;
}
/** Send a response.create immediately and arm the in-flight guard. Any image
* on the payload is added as user content right before the create.
* @param {{ image?: string }} [opts] */
_createResponseNow(opts = {}) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
if (opts.image) this.sendUserImage(opts.image);
this._createInFlight = true;
this._send({ type: "response.create" });
}
/** Replay one queued response.create if the slot is now free. Called on every
* response.done, so queued creates drain one-per-completion. */
_flushQueuedCreate() {
if (this._createQueue.length > 0 && !this._responseActive()) {
const opts = this._createQueue.shift();
if (this._debug) console.debug(`[ws] replaying queued response.create; remaining=${this._createQueue.length}`);
this._createResponseNow(opts);
}
}
/** @param {boolean} muted */
setMuted(muted) {
this._muted = muted;
}
/**
* Update the mic noise gate live (the user moved the Settings cursor).
* @param {NoiseGate} gate
*/
setNoiseGate(gate) {
this._noiseGate = gate;
this._captureNode?.port.postMessage({ kind: "gate", ...gate });
}
/** @param {Record<string, unknown>} event */
_send(event) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
this._ws.send(JSON.stringify(event));
}
async close() {
// Abort a queue wait in progress: flag it and wake the poll sleep so
// `_pollQueue` throws "aborted" and connect() unwinds cleanly.
this._closed = true;
if (this._queueWake) {
clearTimeout(this._queueTimer);
const wake = this._queueWake;
this._queueWake = null;
wake();
}
if (this._joinTimer) {
clearTimeout(this._joinTimer);
this._joinTimer = 0;
}
if (this._joinReject) {
const reject = this._joinReject;
this._joinResolve = null;
this._joinReject = null;
reject(_codedError("join aborted", "aborted"));
}
this._visualiser?.stop();
this._visualiser = null;
try {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) {
this._ws.close(1000, "client closed");
}
} catch {
// ignored
}
this._ws = null;
try {
this._captureNode?.port.close?.();
} catch {
// ignored
}
try {
this._micSrc?.disconnect();
} catch {
// ignored
}
try {
this._captureNode?.disconnect();
} catch {
// ignored
}
try {
this._micAnalyser?.disconnect();
} catch {
// ignored
}
try {
this._outAnalyser?.disconnect();
} catch {
// ignored
}
try {
this._playbackNode?.disconnect();
} catch {
// ignored
}
try {
await this._ctx?.close();
} catch {
// ignored
}
this._ctx = null;
this._captureNode = null;
this._playbackNode = null;
this._micSrc = null;
this._micAnalyser = null;
this._outAnalyser = null;
this._setStatus("closed");
}
}