andito HF Staff commited on
Commit
0d1ecd8
·
1 Parent(s): 98c4bb7

Sync Space with current realtime demo

Browse files
Files changed (7) hide show
  1. README.md +37 -32
  2. index.html +36 -1
  3. main.js +279 -32
  4. rtc/s2s-rtc-client.js +864 -0
  5. server.py +92 -2
  6. style.css +3 -0
  7. ws/s2s-ws-client.js +22 -0
README.md CHANGED
@@ -10,12 +10,12 @@ short_description: Talk directly to Inkling, with replies voiced by Qwen3-TTS
10
  hf_oauth: true
11
  ---
12
 
13
- # Minimal Conversation App (S2S backend, **WebSocket** transport)
14
 
15
- Drop-in alternative to [`amir-tfrere/minimal-conversation-app-s2s-backend`](https://huggingface.co/spaces/amir-tfrere/minimal-conversation-app-s2s-backend)
16
- that uses the **WebSocket** route of the Hugging Face speech-to-speech
17
- backend instead of the WebRTC SDP proxy. Same load balancer, same
18
- `/session` handshake, same UI, same orb. Just a different wire.
19
 
20
  ## How it works
21
 
@@ -43,26 +43,26 @@ backend instead of the WebRTC SDP proxy. Same load balancer, same
43
  The backend exposes one concurrent session per compute (same as WebRTC
44
  mode); the LB pins the session via a signed `session_token`.
45
 
46
- ## Why WebSocket instead of WebRTC
47
 
48
- | | WebRTC (original) | WebSocket (this) |
49
- |---|---|---|
50
- | Transport | UDP + Opus 48 kHz + ICE/STUN | TCP + raw PCM16 |
51
- | NAT traversal | needs STUN, can fail on corporate / cellular | none, works everywhere TCP is allowed |
52
- | Audio quality | excellent (Opus, jitter buffer, FEC) | good (raw PCM, simple ring buffer) |
53
- | Latency | lowest (~50-150 ms) | low (~150-300 ms typical) |
54
- | Echo cancellation | browser AEC active on the WebRTC track | browser AEC active via `getUserMedia` constraints |
55
- | Debuggability | needs `chrome://webrtc-internals` | `wscat` / DevTools network tab |
56
- | Mobile data | sometimes blocked (UDP) | always works (HTTPS+WSS) |
 
 
 
57
 
58
  ## Backend requirement
59
 
60
- This app talks to the WebSocket route `@app.websocket("/v1/realtime")`
61
- defined in
62
- [`websocket_router.py`](https://github.com/huggingface/speech-to-speech/blob/feat/webrtc-transport/src/speech_to_speech/api/openai_realtime/websocket_router.py)
63
- on the **`feat/webrtc-transport`** branch. The same compute serves both
64
- the WebRTC POST and the WebSocket upgrade on the same path; no backend
65
- change required.
66
 
67
  Smoke-test from the shell:
68
 
@@ -94,6 +94,7 @@ Three modes, picked by env (`/api/config` tells the client which one is active):
94
  **directly** to this realtime WebSocket URL; it's shown read-only in Settings.
95
  Setting it disables the load-balancer logic entirely (no `/api/session` proxy,
96
  no queue, no metering, no sign-in). Unlike the LB address it is not a secret.
 
97
  - **`LOAD_BALANCER_URL` env** — the original flow: the browser POSTs the
98
  same-origin `/api/session` proxy, the server forwards to the LB, and the
99
  browser dials the per-session compute URL the LB hands back. The LB address
@@ -106,12 +107,12 @@ Three modes, picked by env (`/api/config` tells the client which one is active):
106
  `connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
107
  (the app adds `/v1/realtime`), and the browser connects to it directly.
108
 
109
- | `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Metering |
110
- |:---:|:---:|:---:|---|---|---|
111
- | ✅ | any | any | direct → pinned URL | visible, locked | off |
112
- | – | ✅ | ✅ | LB proxy | hidden | **on** |
113
- | – | ✅ | – | LB proxy | hidden | off |
114
- | – | – | any | direct → user URL | editable | off |
115
 
116
  **Settings → Restart** reconnects with the current voice, instructions and URL.
117
 
@@ -158,12 +159,15 @@ Then open <http://localhost:7860/>, click the orb, allow the mic, talk.
158
 
159
  | Key | What |
160
  |-----|------|
161
- | Load balancer URL | Base URL of your S2S deployment. App POSTs `<lb>/session`. |
 
 
 
162
  | Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) |
163
- | Instructions | System prompt sent in `session.update` once the WS opens |
164
 
165
- LocalStorage keys are namespaced `s2s.ws.*` so this app's settings do
166
- NOT collide with the WebRTC variant.
167
 
168
  ## Files
169
 
@@ -177,6 +181,7 @@ NOT collide with the WebRTC variant.
177
  | `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
178
  | `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
179
  | `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
 
180
  | `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
181
  | `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
182
  | `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
@@ -200,5 +205,5 @@ NOT collide with the WebRTC variant.
200
 
201
  ## Credits
202
 
203
- - Backend: [huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech) on `feat/webrtc-transport`
204
  - UI verbatim from `amir-tfrere/minimal-conversation-app-s2s-backend` (Pollen Robotics × Hugging Face)
 
10
  hf_oauth: true
11
  ---
12
 
13
+ # Realtime Voice Demo Inkling
14
 
15
+ Browser voice-chat UI for the
16
+ [huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech)
17
+ backend, speaking the OpenAI Realtime **GA** protocol over **WebSocket**
18
+ (the deployed Space path) or **WebRTC** (env-pinned direct deployments only).
19
 
20
  ## How it works
21
 
 
43
  The backend exposes one concurrent session per compute (same as WebRTC
44
  mode); the LB pins the session via a signed `session_token`.
45
 
46
+ ## WebRTC transport
47
 
48
+ With `SPEECH_TO_SPEECH_URL` set, **Settings Transport** offers WebRTC as an
49
+ alternative to WebSocket. The browser sends its SDP offer through the
50
+ same-origin `/api/calls` proxy; negotiated audio and the OpenAI Realtime event
51
+ data channel then flow directly between browser and backend.
52
+
53
+ The proxy forwards only to the env-pinned URL, never to a client-supplied
54
+ target. This is why user-typed URLs and load-balancer mode remain WebSocket-only.
55
+ Set `RTC_ICE_SERVERS` on this app and `SPEECH_TO_SPEECH_ICE_SERVERS` on the
56
+ backend when remote connections need STUN/TURN.
57
+
58
+ The WebSocket-only noise gate is hidden during WebRTC calls. Camera snapshots
59
+ are also re-encoded to fit the data-channel message limit.
60
 
61
  ## Backend requirement
62
 
63
+ The backend must be installed with the `webrtc` extra to serve
64
+ `POST /v1/realtime/calls`; otherwise WebRTC responds with 501. The WebSocket
65
+ route remains `/v1/realtime`.
 
 
 
66
 
67
  Smoke-test from the shell:
68
 
 
94
  **directly** to this realtime WebSocket URL; it's shown read-only in Settings.
95
  Setting it disables the load-balancer logic entirely (no `/api/session` proxy,
96
  no queue, no metering, no sign-in). Unlike the LB address it is not a secret.
97
+ This is the only mode in which WebRTC can be selected.
98
  - **`LOAD_BALANCER_URL` env** — the original flow: the browser POSTs the
99
  same-origin `/api/session` proxy, the server forwards to the LB, and the
100
  browser dials the per-session compute URL the LB hands back. The LB address
 
107
  `connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
108
  (the app adds `/v1/realtime`), and the browser connects to it directly.
109
 
110
+ | `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Transport | Metering |
111
+ |:---:|:---:|:---:|---|---|---|---|
112
+ | ✅ | any | any | direct → pinned URL | visible, locked | WS or WebRTC | off |
113
+ | – | ✅ | ✅ | LB proxy | hidden | WS only | **on** |
114
+ | – | ✅ | – | LB proxy | hidden | WS only | off |
115
+ | – | – | any | direct → user URL | editable | WS only | off |
116
 
117
  **Settings → Restart** reconnects with the current voice, instructions and URL.
118
 
 
159
 
160
  | Key | What |
161
  |-----|------|
162
+ | Speech-to-speech server URL | Direct realtime WebSocket URL (hidden/locked when deployment-owned) |
163
+ | Transport | WebSocket (default) or WebRTC; selectable only with an env-pinned URL |
164
+ | Microphone | Input device for capture. Applies on the next conversation / Restart. |
165
+ | Speakers | Output device for assistant audio. Chrome/Edge can switch live; other browsers keep the system default. |
166
  | Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) |
167
+ | Instructions | System prompt sent in `session.update` once the connection opens |
168
 
169
+ LocalStorage keys are namespaced `s2s.ws.*`, plus `s2s.transport` and
170
+ `s2s.audio.inputId` / `s2s.audio.outputId`.
171
 
172
  ## Files
173
 
 
181
  | `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
182
  | `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
183
  | `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
184
+ | `rtc/s2s-rtc-client.js` | WebRTC sibling: SDP via `/api/calls`, events over data channel, track audio |
185
  | `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
186
  | `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
187
  | `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
 
205
 
206
  ## Credits
207
 
208
+ - Backend: [huggingface/speech-to-speech](https://github.com/huggingface/speech-to-speech)
209
  - UI verbatim from `amir-tfrere/minimal-conversation-app-s2s-backend` (Pollen Robotics × Hugging Face)
index.html CHANGED
@@ -236,6 +236,21 @@
236
  </small>
237
  </label>
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  <div class="field-row">
240
  <label class="field">
241
  <span>Voice</span>
@@ -253,7 +268,27 @@
253
  </label>
254
  </div>
255
 
256
- <div class="field">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  <span class="field-head">
258
  Noise gate
259
  <span id="gate-value" class="field-value">Off</span>
 
236
  </small>
237
  </label>
238
 
239
+ <!-- Transport picker. Hidden in LB mode; disabled (locked to
240
+ WebSocket) unless the deploy pins SPEECH_TO_SPEECH_URL — the
241
+ WebRTC handshake goes through the server-side /api/calls
242
+ proxy, which only forwards to the pinned URL. -->
243
+ <label class="field" id="transport-field" hidden>
244
+ <span>Transport</span>
245
+ <select id="transport">
246
+ <option value="ws" selected>WebSocket</option>
247
+ <option value="webrtc">WebRTC</option>
248
+ </select>
249
+ <small id="transport-hint">
250
+ How audio travels to the server. Applies on the next conversation.
251
+ </small>
252
+ </label>
253
+
254
  <div class="field-row">
255
  <label class="field">
256
  <span>Voice</span>
 
268
  </label>
269
  </div>
270
 
271
+ <label class="field">
272
+ <span>Microphone</span>
273
+ <select id="audio-input">
274
+ <option value="">System default</option>
275
+ </select>
276
+ <small>Applies on the next conversation (or Restart).</small>
277
+ </label>
278
+
279
+ <label class="field" id="audio-output-field">
280
+ <span>Speakers</span>
281
+ <select id="audio-output">
282
+ <option value="">System default</option>
283
+ </select>
284
+ <small id="audio-output-hint">
285
+ Where assistant audio plays. Chrome/Edge support switching outputs.
286
+ </small>
287
+ </label>
288
+
289
+ <!-- WS-only: the gate runs in the WebSocket capture worklet; the
290
+ WebRTC mic path sends the raw track, so this hides there. -->
291
+ <div class="field" id="gate-field">
292
  <span class="field-head">
293
  Noise gate
294
  <span id="gate-value" class="field-value">Off</span>
main.js CHANGED
@@ -1,22 +1,24 @@
1
  // @ts-check
2
  /**
3
  * Minimal voice conversation app, talking to a Hugging Face speech-to-speech
4
- * backend over **WebSocket** (drop-in alternative to the WebRTC variant).
5
  *
6
- * Click the orb -> we ask for the mic, POST a session on the LB, open a
7
- * WebSocket on the routed compute endpoint, push session.update + mic
8
- * audio, play back the TTS audio. The orb visually reflects the live
9
- * state (idle, connecting, listening, user-speaking, processing,
10
- * ai-speaking).
11
  *
12
- * The only meaningful difference vs. the WebRTC main.js is that the
13
- * client owns its own AudioContext (no `attachOutputTrack`), so we hand
14
- * it the MediaStream directly.
 
15
  *
16
  * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
 
17
  */
18
 
19
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
 
20
  import { $, truncateError, DEBUG } from "./ui/dom.js";
21
  import { ChatView } from "./ui/chat.js";
22
  import { Account } from "./ui/account.js";
@@ -47,6 +49,11 @@ const STORAGE_KEYS = {
47
  tools: "s2s.ws.tools",
48
  searchKey: "s2s.ws.searchKey",
49
  noiseGate: "s2s.ws.noiseGate",
 
 
 
 
 
50
  };
51
 
52
  // ── Noise gate ──────────────────────────────────────────────────────────────
@@ -98,6 +105,21 @@ const TOOL_DEFS = {
98
  /** Longest edge of the snapshot sent to the VLM, in px (keeps payload sane). */
99
  const SNAPSHOT_MAX_EDGE = 768;
100
  const SNAPSHOT_QUALITY = 0.7;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  function loadSettings() {
103
  return {
@@ -105,6 +127,10 @@ function loadSettings() {
105
  voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
106
  instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
107
  noiseGate: loadGateThreshold(),
 
 
 
 
108
  };
109
  }
110
 
@@ -127,6 +153,9 @@ function saveSettings(s) {
127
  localStorage.setItem(STORAGE_KEYS.voice, s.voice);
128
  localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
129
  localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
 
 
 
130
  }
131
 
132
  /** @returns {{ web_search: boolean, camera_snapshot: boolean }} */
@@ -239,8 +268,22 @@ const inputLbUrl = $("#lb-url");
239
  const connField = $("#conn-field");
240
  /** @type {HTMLElement} */
241
  const connHint = $("#conn-hint");
 
 
 
 
 
 
 
 
242
  /** @type {HTMLSelectElement} */
243
  const inputVoice = $("#voice");
 
 
 
 
 
 
244
  /** @type {HTMLTextAreaElement} */
245
  const inputInstructions = $("#instructions");
246
  /** @type {HTMLInputElement} */
@@ -283,6 +326,17 @@ let allowDirect = true;
283
  // Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
284
  // mode: the field displays it read-only and the saved user URL is untouched.
285
  let pinnedUrl = "";
 
 
 
 
 
 
 
 
 
 
 
286
 
287
  // ── Tool state ──────────────────────────────────────────────────────────────
288
  let toolsEnabled = loadTools();
@@ -339,7 +393,7 @@ let trackedTier = "";
339
  // queue on teardown / tab-close so we don't hold a phantom place.
340
  let queuedTicketId = "";
341
 
342
- /** @type {S2sWsRealtimeClient | null} */
343
  let client = null;
344
  /** @type {MediaStream | null} */
345
  let micStream = null;
@@ -411,6 +465,7 @@ function openSettings() {
411
  inputInstructions.value = settings.instructions;
412
  syncGateUi();
413
  updateRestartAvailability();
 
414
  settingsModal.showModal();
415
  }
416
 
@@ -712,22 +767,44 @@ async function watchCameraPermission() {
712
  * Grab the current webcam frame as a downscaled JPEG data URL. The preview is
713
  * mirrored in CSS for a natural self-view, but we draw the raw (un-mirrored)
714
  * video here so the model sees the scene in its true orientation.
 
 
 
 
715
  * @returns {string | null}
716
  */
717
  function captureSnapshot() {
718
  if (!cameraStream || !camVideo.videoWidth) return null;
719
  const vw = camVideo.videoWidth;
720
  const vh = camVideo.videoHeight;
721
- const scale = Math.min(1, SNAPSHOT_MAX_EDGE / Math.max(vw, vh));
722
- const w = Math.max(1, Math.round(vw * scale));
723
- const h = Math.max(1, Math.round(vh * scale));
724
- const canvas = document.createElement("canvas");
725
- canvas.width = w;
726
- canvas.height = h;
727
- const ctx = canvas.getContext("2d");
728
- if (!ctx) return null;
729
- ctx.drawImage(camVideo, 0, 0, w, h);
730
- return canvas.toDataURL("image/jpeg", SNAPSHOT_QUALITY);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  }
732
 
733
  /** Brief shutter flash on the preview so the user sees a snapshot was taken. */
@@ -840,6 +917,10 @@ async function fetchConfig() {
840
  allowDirect = json.allowDirect ?? !lbMode;
841
  // Deploy-pinned direct URL (overrides the LB server-side already).
842
  pinnedUrl = (json.s2sUrl || "").trim();
 
 
 
 
843
  // The conversation-time limiter rides on the LB being present.
844
  limiterOn = lbMode;
845
  }
@@ -916,13 +997,22 @@ function createResumedAudioContext() {
916
 
917
  /** Read the editable settings out of the form. The URL field is only honoured
918
  * in free direct mode — in LB mode it's hidden, and when the deploy pins a
919
- * URL it's read-only, so the user's saved URL survives either way. */
 
 
920
  function readSettingsFromForm() {
921
  return {
922
  directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl,
923
  voice: inputVoice.value || DEFAULT_VOICE,
924
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
925
  noiseGate: readGateThreshold(),
 
 
 
 
 
 
 
926
  };
927
  }
928
 
@@ -933,8 +1023,37 @@ function readGateThreshold() {
933
  return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, v));
934
  }
935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  /** Adapt the connection field to the mode learned from /api/config. */
937
  function syncConnectionUi() {
 
938
  if (pinnedUrl) {
939
  // Deploy-pinned URL: show it, but locked — the deployment owns it.
940
  connField.hidden = false;
@@ -981,9 +1100,14 @@ settingsForm.addEventListener("submit", (event) => {
981
  saveSettings(settings);
982
 
983
  // Voice + instructions can apply to a live session without reconnecting; a
984
- // changed connection URL only takes effect on the next restart.
 
 
985
  if (client && LIVE_STATES.has(currentState)) {
986
  client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() });
 
 
 
987
  }
988
  });
989
 
@@ -993,6 +1117,15 @@ inputNoiseGate.addEventListener("input", () => {
993
  setGateThreshold(readGateThreshold());
994
  });
995
 
 
 
 
 
 
 
 
 
 
996
  restartBtn.addEventListener("click", async () => {
997
  if (currentState === "connecting") return; // a connect is already underway
998
  settings = readSettingsFromForm();
@@ -1084,16 +1217,112 @@ joinQueueBtn.addEventListener("click", () => {
1084
  if (client) client.join();
1085
  });
1086
 
1087
- const MIC_CONSTRAINTS = {
1088
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
 
 
1089
  };
1090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
  /** Prompt for mic permission up front, then immediately release the tracks so no
1092
  * recording indicator lingers during a queue wait. Throws a friendly error if the
1093
  * user denies. */
1094
  async function primeMicPermission() {
1095
  try {
1096
- const s = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
1097
  for (const track of s.getTracks()) track.stop();
1098
  } catch (err) {
1099
  throw new Error(
@@ -1105,7 +1334,7 @@ async function primeMicPermission() {
1105
  /** Acquire the live capture stream once a slot is granted. Permission was primed
1106
  * in the tap gesture, so this is silent. Stored module-side for mute + teardown. */
1107
  async function acquireMicStream() {
1108
- micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
1109
  return micStream;
1110
  }
1111
 
@@ -1153,9 +1382,16 @@ function stopJoinCountdown() {
1153
  * @param {AudioContext | null} [audioContext]
1154
  */
1155
  async function doStart(audioContext = null) {
 
1156
  // Resolve the target before touching mic/audio so a misconfiguration (e.g.
1157
- // direct mode with no URL) fails fast with a clear message.
1158
- const target = connectionTarget();
 
 
 
 
 
 
1159
 
1160
  chat.clear();
1161
  chat.reset();
@@ -1182,16 +1418,26 @@ async function doStart(audioContext = null) {
1182
  // The webcam is started on arrival (autoStartCamera), so nothing to do here;
1183
  // a still-pending grant just means the snapshot tool isn't ready yet.
1184
 
1185
- const c = new S2sWsRealtimeClient({
1186
- ...target,
1187
  voice: settings.voice,
1188
  instructions: effectiveInstructions(),
1189
  startupGreeting: STARTUP_GREETING_PROMPT,
1190
  acquireMic: acquireMicStream,
1191
  tools: activeToolDefs(),
1192
- noiseGate: gateParams(settings.noiseGate),
1193
  ...(audioContext ? { audioContext } : {}),
1194
- });
 
 
 
 
 
 
 
 
 
 
 
1195
  client = c;
1196
 
1197
  c.addEventListener("queue", (e) => {
@@ -1410,6 +1656,7 @@ async function teardown() {
1410
  // on the page), so we leave it on here — only the camera toggle stops it.
1411
  micMuted = false;
1412
  micBtn.classList.remove("muted");
 
1413
  setState("idle");
1414
  // Refresh the chip's remaining-today after the budget moved.
1415
  if (limiterOn) void account.refresh();
 
1
  // @ts-check
2
  /**
3
  * Minimal voice conversation app, talking to a Hugging Face speech-to-speech
4
+ * backend over **WebSocket** or **WebRTC**.
5
  *
6
+ * Click the orb -> we ask for the mic, connect (WS dial, or SDP handshake via
7
+ * the /api/calls proxy), push session.update + mic audio, play back the TTS
8
+ * audio. The orb visually reflects the live state (idle, connecting,
9
+ * listening, user-speaking, processing, ai-speaking).
 
10
  *
11
+ * The two client classes expose the same events and methods, so everything
12
+ * below except the constructor pick is transport-agnostic. WebRTC is offered
13
+ * only in env-pinned direct mode (the /api/calls proxy forwards exclusively
14
+ * to SPEECH_TO_SPEECH_URL); LB mode and user-typed URLs stay on WebSocket.
15
  *
16
  * @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
17
+ * @typedef {S2sWsRealtimeClient | S2sRtcRealtimeClient} RealtimeClient
18
  */
19
 
20
  import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
21
+ import { S2sRtcRealtimeClient } from "./rtc/s2s-rtc-client.js";
22
  import { $, truncateError, DEBUG } from "./ui/dom.js";
23
  import { ChatView } from "./ui/chat.js";
24
  import { Account } from "./ui/account.js";
 
49
  tools: "s2s.ws.tools",
50
  searchKey: "s2s.ws.searchKey",
51
  noiseGate: "s2s.ws.noiseGate",
52
+ // "ws" | "webrtc". Not under the historical "s2s.ws." prefix — it selects
53
+ // between the transports rather than configuring the WS one.
54
+ transport: "s2s.transport",
55
+ audioInputId: "s2s.audio.inputId",
56
+ audioOutputId: "s2s.audio.outputId",
57
  };
58
 
59
  // ── Noise gate ──────────────────────────────────────────────────────────────
 
105
  /** Longest edge of the snapshot sent to the VLM, in px (keeps payload sane). */
106
  const SNAPSHOT_MAX_EDGE = 768;
107
  const SNAPSHOT_QUALITY = 0.7;
108
+ // Over WebRTC the snapshot travels inside ONE data-channel message, and SCTP
109
+ // messages above the negotiated max (64 KiB on the aiortc side) fail to send.
110
+ // Budget for the data-URL portion, leaving headroom for the JSON envelope.
111
+ const SNAPSHOT_DC_BUDGET_CHARS = 60_000;
112
+ // Re-encode ladder walked until the frame fits the budget: quality first
113
+ // (cheap wins), then resolution. The last rung is ~15–25 KB for any content,
114
+ // so a frame that "fits" is deterministic, not content-dependent luck.
115
+ const SNAPSHOT_LADDER = /** @type {[number, number][]} */ ([
116
+ [SNAPSHOT_MAX_EDGE, SNAPSHOT_QUALITY],
117
+ [SNAPSHOT_MAX_EDGE, 0.5],
118
+ [640, 0.45],
119
+ [512, 0.4],
120
+ [448, 0.35],
121
+ [384, 0.3],
122
+ ]);
123
 
124
  function loadSettings() {
125
  return {
 
127
  voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
128
  instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
129
  noiseGate: loadGateThreshold(),
130
+ // Default WebSocket: the proven path stays the first-run experience.
131
+ transport: localStorage.getItem(STORAGE_KEYS.transport) === "webrtc" ? "webrtc" : "ws",
132
+ audioInputId: localStorage.getItem(STORAGE_KEYS.audioInputId) || "",
133
+ audioOutputId: localStorage.getItem(STORAGE_KEYS.audioOutputId) || "",
134
  };
135
  }
136
 
 
153
  localStorage.setItem(STORAGE_KEYS.voice, s.voice);
154
  localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
155
  localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
156
+ localStorage.setItem(STORAGE_KEYS.transport, s.transport);
157
+ localStorage.setItem(STORAGE_KEYS.audioInputId, s.audioInputId || "");
158
+ localStorage.setItem(STORAGE_KEYS.audioOutputId, s.audioOutputId || "");
159
  }
160
 
161
  /** @returns {{ web_search: boolean, camera_snapshot: boolean }} */
 
268
  const connField = $("#conn-field");
269
  /** @type {HTMLElement} */
270
  const connHint = $("#conn-hint");
271
+ /** @type {HTMLElement} */
272
+ const transportField = $("#transport-field");
273
+ /** @type {HTMLSelectElement} */
274
+ const inputTransport = $("#transport");
275
+ /** @type {HTMLElement} */
276
+ const transportHint = $("#transport-hint");
277
+ /** @type {HTMLElement} */
278
+ const gateField = $("#gate-field");
279
  /** @type {HTMLSelectElement} */
280
  const inputVoice = $("#voice");
281
+ /** @type {HTMLSelectElement} */
282
+ const inputAudioInput = $("#audio-input");
283
+ /** @type {HTMLSelectElement} */
284
+ const inputAudioOutput = $("#audio-output");
285
+ /** @type {HTMLElement} */
286
+ const audioOutputHint = $("#audio-output-hint");
287
  /** @type {HTMLTextAreaElement} */
288
  const inputInstructions = $("#instructions");
289
  /** @type {HTMLInputElement} */
 
326
  // Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
327
  // mode: the field displays it read-only and the saved user URL is untouched.
328
  let pinnedUrl = "";
329
+ // Whether the deploy offers the WebRTC transport (/api/config `rtc`; true
330
+ // exactly when the URL is env-pinned, since /api/calls only forwards there).
331
+ let rtcAvailable = false;
332
+ /** @type {RTCIceServer[]} STUN/TURN servers for the browser peer connection
333
+ * (deploy-provided via RTC_ICE_SERVERS; empty -> host candidates only). */
334
+ let iceServers = [];
335
+ // Transport of the LIVE (or starting) conversation — as opposed to
336
+ // `settings.transport`, which is what the NEXT one will use. Drives the
337
+ // camera-snapshot size budget while a call is running.
338
+ /** @type {"ws" | "webrtc"} */
339
+ let activeTransport = "ws";
340
 
341
  // ── Tool state ──────────────────────────────────────────────────────────────
342
  let toolsEnabled = loadTools();
 
393
  // queue on teardown / tab-close so we don't hold a phantom place.
394
  let queuedTicketId = "";
395
 
396
+ /** @type {RealtimeClient | null} */
397
  let client = null;
398
  /** @type {MediaStream | null} */
399
  let micStream = null;
 
465
  inputInstructions.value = settings.instructions;
466
  syncGateUi();
467
  updateRestartAvailability();
468
+ void refreshAudioDeviceLists();
469
  settingsModal.showModal();
470
  }
471
 
 
767
  * Grab the current webcam frame as a downscaled JPEG data URL. The preview is
768
  * mirrored in CSS for a natural self-view, but we draw the raw (un-mirrored)
769
  * video here so the model sees the scene in its true orientation.
770
+ *
771
+ * Over WebRTC the frame must fit one data-channel message, so it's re-encoded
772
+ * down the SNAPSHOT_LADDER until it's under SNAPSHOT_DC_BUDGET_CHARS; over
773
+ * WebSocket the first (full-quality) rung is used as before.
774
  * @returns {string | null}
775
  */
776
  function captureSnapshot() {
777
  if (!cameraStream || !camVideo.videoWidth) return null;
778
  const vw = camVideo.videoWidth;
779
  const vh = camVideo.videoHeight;
780
+
781
+ /** @param {number} maxEdge @param {number} quality @returns {string | null} */
782
+ const encode = (maxEdge, quality) => {
783
+ const scale = Math.min(1, maxEdge / Math.max(vw, vh));
784
+ const w = Math.max(1, Math.round(vw * scale));
785
+ const h = Math.max(1, Math.round(vh * scale));
786
+ const canvas = document.createElement("canvas");
787
+ canvas.width = w;
788
+ canvas.height = h;
789
+ const ctx = canvas.getContext("2d");
790
+ if (!ctx) return null;
791
+ ctx.drawImage(camVideo, 0, 0, w, h);
792
+ return canvas.toDataURL("image/jpeg", quality);
793
+ };
794
+
795
+ if (activeTransport !== "webrtc") {
796
+ return encode(SNAPSHOT_MAX_EDGE, SNAPSHOT_QUALITY);
797
+ }
798
+ let dataUrl = null;
799
+ for (const [edge, quality] of SNAPSHOT_LADDER) {
800
+ dataUrl = encode(edge, quality);
801
+ if (!dataUrl) return null;
802
+ if (dataUrl.length <= SNAPSHOT_DC_BUDGET_CHARS) return dataUrl;
803
+ }
804
+ // Even the last rung overflowed (shouldn't happen in practice) — send it
805
+ // anyway; the client logs the failed send rather than killing the session.
806
+ console.warn(`[tool] snapshot exceeds the data-channel budget after the full ladder (${dataUrl?.length} chars)`);
807
+ return dataUrl;
808
  }
809
 
810
  /** Brief shutter flash on the preview so the user sees a snapshot was taken. */
 
917
  allowDirect = json.allowDirect ?? !lbMode;
918
  // Deploy-pinned direct URL (overrides the LB server-side already).
919
  pinnedUrl = (json.s2sUrl || "").trim();
920
+ // WebRTC transport: offered only when the deploy pins the URL (the
921
+ // /api/calls proxy refuses to forward anywhere else).
922
+ rtcAvailable = !!json.rtc;
923
+ iceServers = Array.isArray(json.iceServers) ? json.iceServers : [];
924
  // The conversation-time limiter rides on the LB being present.
925
  limiterOn = lbMode;
926
  }
 
997
 
998
  /** Read the editable settings out of the form. The URL field is only honoured
999
  * in free direct mode — in LB mode it's hidden, and when the deploy pins a
1000
+ * URL it's read-only, so the user's saved URL survives either way. The
1001
+ * transport select is only honoured while selectable, so a saved "webrtc"
1002
+ * survives a visit to a deploy that can't offer it. */
1003
  function readSettingsFromForm() {
1004
  return {
1005
  directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl,
1006
  voice: inputVoice.value || DEFAULT_VOICE,
1007
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
1008
  noiseGate: readGateThreshold(),
1009
+ transport: /** @type {"ws" | "webrtc"} */ (
1010
+ transportSelectable()
1011
+ ? (inputTransport.value === "webrtc" ? "webrtc" : "ws")
1012
+ : settings.transport
1013
+ ),
1014
+ audioInputId: inputAudioInput.value || "",
1015
+ audioOutputId: inputAudioOutput.value || "",
1016
  };
1017
  }
1018
 
 
1023
  return Math.min(GATE_MAX_DB, Math.max(GATE_OFF_DB, v));
1024
  }
1025
 
1026
+ /** Whether the transport picker is live: env-pinned direct mode only. The
1027
+ * /api/calls proxy forwards exclusively to SPEECH_TO_SPEECH_URL, so without
1028
+ * the pin there is nowhere safe to send a WebRTC offer. */
1029
+ function transportSelectable() {
1030
+ return allowDirect && !!pinnedUrl && rtcAvailable;
1031
+ }
1032
+
1033
+ /** The transport the next conversation will actually use. */
1034
+ function effectiveTransport() {
1035
+ return transportSelectable() && settings.transport === "webrtc" ? "webrtc" : "ws";
1036
+ }
1037
+
1038
+ /** Reflect transport availability + selection into Settings, and hide the
1039
+ * noise gate when WebRTC is picked (the gate lives in the WS capture
1040
+ * worklet; the WebRTC mic path sends the raw track). */
1041
+ function syncTransportUi() {
1042
+ // Hidden in LB mode (nothing to choose); visible-but-locked in un-pinned
1043
+ // direct mode so the option is discoverable along with what unlocks it.
1044
+ transportField.hidden = !allowDirect;
1045
+ const selectable = transportSelectable();
1046
+ inputTransport.disabled = !selectable;
1047
+ inputTransport.value = selectable && settings.transport === "webrtc" ? "webrtc" : "ws";
1048
+ transportHint.textContent = selectable
1049
+ ? "How audio travels to the server. Applies on the next conversation."
1050
+ : "WebRTC needs a server URL pinned by the deployment (SPEECH_TO_SPEECH_URL).";
1051
+ gateField.hidden = effectiveTransport() === "webrtc";
1052
+ }
1053
+
1054
  /** Adapt the connection field to the mode learned from /api/config. */
1055
  function syncConnectionUi() {
1056
+ syncTransportUi();
1057
  if (pinnedUrl) {
1058
  // Deploy-pinned URL: show it, but locked — the deployment owns it.
1059
  connField.hidden = false;
 
1100
  saveSettings(settings);
1101
 
1102
  // Voice + instructions can apply to a live session without reconnecting; a
1103
+ // changed connection URL only takes effect on the next restart. Speaker
1104
+ // output can switch live when the browser supports AudioContext.setSinkId;
1105
+ // mic device changes need a Restart (new getUserMedia stream).
1106
  if (client && LIVE_STATES.has(currentState)) {
1107
  client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() });
1108
+ if (typeof client.setAudioOutputDevice === "function") {
1109
+ void client.setAudioOutputDevice(settings.audioOutputId);
1110
+ }
1111
  }
1112
  });
1113
 
 
1117
  setGateThreshold(readGateThreshold());
1118
  });
1119
 
1120
+ // Transport persists on change (like the gate) and takes effect on the next
1121
+ // conversation; the gate field previews its WS-only availability right away.
1122
+ inputTransport.addEventListener("change", () => {
1123
+ if (!transportSelectable()) return;
1124
+ settings.transport = inputTransport.value === "webrtc" ? "webrtc" : "ws";
1125
+ localStorage.setItem(STORAGE_KEYS.transport, settings.transport);
1126
+ syncTransportUi();
1127
+ });
1128
+
1129
  restartBtn.addEventListener("click", async () => {
1130
  if (currentState === "connecting") return; // a connect is already underway
1131
  settings = readSettingsFromForm();
 
1217
  if (client) client.join();
1218
  });
1219
 
1220
+ const MIC_CONSTRAINTS_BASE = {
1221
+ echoCancellation: true,
1222
+ noiseSuppression: true,
1223
+ autoGainControl: true,
1224
  };
1225
 
1226
+ /** @returns {MediaStreamConstraints} */
1227
+ function micConstraints() {
1228
+ /** @type {MediaTrackConstraints} */
1229
+ const audio = { ...MIC_CONSTRAINTS_BASE };
1230
+ if (settings.audioInputId) {
1231
+ // ideal (not exact): if the saved device was unplugged, fall back quietly.
1232
+ audio.deviceId = { ideal: settings.audioInputId };
1233
+ }
1234
+ return { audio };
1235
+ }
1236
+
1237
+ /** True when Web Audio can route playback to a chosen output device. */
1238
+ function supportsAudioOutputSelection() {
1239
+ const Ctx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext;
1240
+ return typeof Ctx?.prototype?.setSinkId === "function";
1241
+ }
1242
+
1243
+ /**
1244
+ * Rebuild the mic/speaker <select>s from enumerateDevices. Labels are blank
1245
+ * until mic permission has been granted at least once.
1246
+ */
1247
+ async function refreshAudioDeviceLists() {
1248
+ const canPickOutput = supportsAudioOutputSelection();
1249
+ inputAudioOutput.disabled = !canPickOutput;
1250
+ audioOutputHint.textContent = canPickOutput
1251
+ ? "Where assistant audio plays. Can change live while connected."
1252
+ : "Speaker selection needs a browser with AudioContext.setSinkId (Chrome/Edge).";
1253
+
1254
+ /** @type {MediaDeviceInfo[]} */
1255
+ let devices = [];
1256
+ try {
1257
+ devices = await navigator.mediaDevices.enumerateDevices();
1258
+ } catch (err) {
1259
+ console.warn("[main] enumerateDevices failed:", err);
1260
+ }
1261
+
1262
+ const inputs = devices.filter((d) => d.kind === "audioinput");
1263
+ const outputs = devices.filter((d) => d.kind === "audiooutput");
1264
+ const labelsReady = devices.some((d) => d.label);
1265
+
1266
+ fillDeviceSelect(inputAudioInput, inputs, settings.audioInputId, "Microphone");
1267
+ fillDeviceSelect(inputAudioOutput, outputs, settings.audioOutputId, "Speaker");
1268
+
1269
+ if (!labelsReady) {
1270
+ // Permission unlocks real device names; keep it quiet — user can tap Start
1271
+ // or we unlock when they already connected once this session.
1272
+ const hint = inputAudioInput.parentElement?.querySelector("small");
1273
+ if (hint) {
1274
+ hint.textContent =
1275
+ "Allow microphone access (tap Start once) to see device names. Mic changes apply on Restart.";
1276
+ }
1277
+ } else {
1278
+ const hint = inputAudioInput.parentElement?.querySelector("small");
1279
+ if (hint) hint.textContent = "Applies on the next conversation (or Restart).";
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * @param {HTMLSelectElement} select
1285
+ * @param {MediaDeviceInfo[]} devices
1286
+ * @param {string} selectedId
1287
+ * @param {string} fallbackLabel
1288
+ */
1289
+ function fillDeviceSelect(select, devices, selectedId, fallbackLabel) {
1290
+ const prev = selectedId || select.value || "";
1291
+ select.replaceChildren();
1292
+ const def = document.createElement("option");
1293
+ def.value = "";
1294
+ def.textContent = "System default";
1295
+ select.appendChild(def);
1296
+ devices.forEach((d, i) => {
1297
+ const opt = document.createElement("option");
1298
+ opt.value = d.deviceId;
1299
+ opt.textContent = d.label || `${fallbackLabel} ${i + 1}`;
1300
+ select.appendChild(opt);
1301
+ });
1302
+ // Keep a saved id even if it isn't currently listed (unplugged); browser
1303
+ // will fall back via ideal constraints / setSinkId errors.
1304
+ if (prev && ![...select.options].some((o) => o.value === prev)) {
1305
+ const missing = document.createElement("option");
1306
+ missing.value = prev;
1307
+ missing.textContent = `${fallbackLabel} (saved, not found)`;
1308
+ select.appendChild(missing);
1309
+ }
1310
+ select.value = prev;
1311
+ if (select.value !== prev) select.value = "";
1312
+ }
1313
+
1314
+ if (navigator.mediaDevices?.addEventListener) {
1315
+ navigator.mediaDevices.addEventListener("devicechange", () => {
1316
+ if (settingsModal.open) void refreshAudioDeviceLists();
1317
+ });
1318
+ }
1319
+
1320
  /** Prompt for mic permission up front, then immediately release the tracks so no
1321
  * recording indicator lingers during a queue wait. Throws a friendly error if the
1322
  * user denies. */
1323
  async function primeMicPermission() {
1324
  try {
1325
+ const s = await navigator.mediaDevices.getUserMedia(micConstraints());
1326
  for (const track of s.getTracks()) track.stop();
1327
  } catch (err) {
1328
  throw new Error(
 
1334
  /** Acquire the live capture stream once a slot is granted. Permission was primed
1335
  * in the tap gesture, so this is silent. Stored module-side for mute + teardown. */
1336
  async function acquireMicStream() {
1337
+ micStream = await navigator.mediaDevices.getUserMedia(micConstraints());
1338
  return micStream;
1339
  }
1340
 
 
1382
  * @param {AudioContext | null} [audioContext]
1383
  */
1384
  async function doStart(audioContext = null) {
1385
+ const transport = effectiveTransport();
1386
  // Resolve the target before touching mic/audio so a misconfiguration (e.g.
1387
+ // direct mode with no URL) fails fast with a clear message. Over WebRTC the
1388
+ // browser never dials the s2s server itself — the offer goes to the
1389
+ // same-origin /api/calls proxy — so there is no target to resolve.
1390
+ const target = transport === "webrtc" ? null : connectionTarget();
1391
+ activeTransport = transport;
1392
+ // The radial gate arc (threshold handle around the mic button) is a WS
1393
+ // feature; over WebRTC only the mute button remains.
1394
+ document.body.classList.toggle("rtc-live", transport === "webrtc");
1395
 
1396
  chat.clear();
1397
  chat.reset();
 
1418
  // The webcam is started on arrival (autoStartCamera), so nothing to do here;
1419
  // a still-pending grant just means the snapshot tool isn't ready yet.
1420
 
1421
+ const common = {
 
1422
  voice: settings.voice,
1423
  instructions: effectiveInstructions(),
1424
  startupGreeting: STARTUP_GREETING_PROMPT,
1425
  acquireMic: acquireMicStream,
1426
  tools: activeToolDefs(),
1427
+ audioOutputId: settings.audioOutputId || "",
1428
  ...(audioContext ? { audioContext } : {}),
1429
+ };
1430
+ const c = target === null
1431
+ ? new S2sRtcRealtimeClient({
1432
+ callsUrl: "api/calls",
1433
+ iceServers,
1434
+ ...common,
1435
+ })
1436
+ : new S2sWsRealtimeClient({
1437
+ ...target,
1438
+ noiseGate: gateParams(settings.noiseGate),
1439
+ ...common,
1440
+ });
1441
  client = c;
1442
 
1443
  c.addEventListener("queue", (e) => {
 
1656
  // on the page), so we leave it on here — only the camera toggle stops it.
1657
  micMuted = false;
1658
  micBtn.classList.remove("muted");
1659
+ document.body.classList.remove("rtc-live");
1660
  setState("idle");
1661
  // Refresh the chip's remaining-today after the budget moved.
1662
  if (limiterOn) void account.refresh();
rtc/s2s-rtc-client.js ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * Minimal WebRTC client for a speech-to-speech realtime endpoint.
4
+ *
5
+ * Sibling of `ws/s2s-ws-client.js` with the SAME public surface (constructor
6
+ * options subset, methods, dispatched events), so `main.js` can pick either
7
+ * class from the transport setting and wire it identically. Direct mode only:
8
+ * there is no load-balancer/queue path over WebRTC yet.
9
+ *
10
+ * Handshake (OpenAI Realtime GA "calls" endpoint, via the same-origin proxy):
11
+ *
12
+ * 1. getUserMedia -> add the mic track + create the `oai-events` data
13
+ * channel on an RTCPeerConnection, `createOffer`, wait for ICE gathering.
14
+ * 2. POST the offer SDP (Content-Type: application/sdp) to `callsUrl`
15
+ * (usually the same-origin `api/calls` proxy, which forwards it to the
16
+ * s2s server's /v1/realtime/calls) -> 201 + answer SDP.
17
+ * 3. `setRemoteDescription(answer)`; once the data channel opens the server
18
+ * pushes `session.created` and we reply with `session.update`.
19
+ *
20
+ * After that the JSON protocol on the data channel is the one the WebSocket
21
+ * transport speaks, with the audio moved out of it:
22
+ *
23
+ * - Mic audio rides the RTP media track (Opus), NOT
24
+ * `input_audio_buffer.append` — the server rejects `append` over WebRTC.
25
+ * - Assistant audio arrives as a remote media track, NOT as
26
+ * `response.output_audio.delta` events. There is no client playback
27
+ * buffer: barge-in flushing happens server-side, so `speech_started`
28
+ * only needs to flip the UI state here.
29
+ *
30
+ * Because no audio events exist, "the assistant is audibly speaking" is
31
+ * detected from the output analyser's RMS (with a short hang time), and
32
+ * `response.done` / `speech_started` remain the authoritative exits — the
33
+ * same status contract the WS client exposes.
34
+ *
35
+ * @typedef {"idle" | "connecting" | "connected" | "user-speaking" |
36
+ * "processing" | "ai-speaking" | "closed" | "error"
37
+ * } RtcStatus
38
+ *
39
+ * @typedef {Object} RtcClientOptions
40
+ * @property {string} callsUrl URL to POST the SDP offer to (same-origin proxy
41
+ * like `api/calls`, or a direct `/v1/realtime/calls` URL when CORS allows).
42
+ * @property {RTCIceServer[]} [iceServers] STUN/TURN servers for the peer
43
+ * connection (from /api/config). Empty/absent -> browser defaults (host
44
+ * candidates only — fine locally, may not traverse NATs).
45
+ * @property {string} voice
46
+ * @property {string} instructions
47
+ * @property {string} [startupGreeting] Hidden user prompt that asks the model
48
+ * to greet once after the initial session configuration.
49
+ * @property {MediaStream} [micStream] Live mic stream. Provide this OR `acquireMic`.
50
+ * @property {() => Promise<MediaStream>} [acquireMic] Lazily obtain the mic
51
+ * stream once connect() actually runs (same contract as the WS client).
52
+ * @property {AudioContext} [audioContext] Pre-created (and resumed) context —
53
+ * created inside the tap gesture so iOS lets it start.
54
+ * @property {import("../ws/s2s-ws-client.js").ToolDef[]} [tools] Function tools
55
+ * declared in the initial `session.update`.
56
+ * @property {string} [audioOutputId] MediaDeviceInfo.deviceId for speakers
57
+ * (AudioContext.setSinkId when supported).
58
+ */
59
+
60
+ import { extractResponseTranscript } from "../ws/codec.js";
61
+ import { OrbVisualiser, VIS_FFT_SIZE } from "../ws/orb-visualizer.js";
62
+
63
+ // The server's data channel label (aiortc side ignores any other label).
64
+ const DATA_CHANNEL_LABEL = "oai-events";
65
+ // Give up on the handshake if the data channel hasn't opened this long after
66
+ // the SDP answer was applied (ICE failed silently, e.g. NAT without STUN).
67
+ // The server holds its slot behind a 30 s watchdog; stay under it.
68
+ const DC_OPEN_TIMEOUT_MS = 20_000;
69
+ // Don't wait forever for ICE gathering before POSTing the offer — host
70
+ // candidates land near-instantly; STUN answers within a couple of seconds.
71
+ const ICE_GATHERING_TIMEOUT_MS = 3_000;
72
+ // Output-RMS gate for "the assistant is audibly speaking": open above the
73
+ // threshold, and hang on briefly so inter-word gaps don't flap the status.
74
+ const SPEAKING_OPEN_DB = -50;
75
+ const SPEAKING_HANG_MS = 250;
76
+ const LEVEL_POLL_MS = 50;
77
+
78
+ /** Build an Error carrying a `code` so callers can branch on the failure kind.
79
+ * @param {string} message @param {string} code */
80
+ function _codedError(message, code) {
81
+ const err = /** @type {Error & { code?: string }} */ (new Error(message));
82
+ err.code = code;
83
+ return err;
84
+ }
85
+
86
+ export class S2sRtcRealtimeClient extends EventTarget {
87
+ /** @param {RtcClientOptions} options */
88
+ constructor(options) {
89
+ super();
90
+ /** @type {RtcClientOptions} */
91
+ this.options = options;
92
+ /** @type {import("../ws/s2s-ws-client.js").ToolDef[]} */
93
+ this._tools = options.tools ?? [];
94
+ /** @type {(() => Promise<MediaStream>) | null} */
95
+ this._acquireMic = options.acquireMic ?? null;
96
+ /** @type {boolean} Set by close() so late async steps unwind quietly. */
97
+ this._closed = false;
98
+ /** @type {RTCPeerConnection | null} */
99
+ this._pc = null;
100
+ /** @type {RTCDataChannel | null} */
101
+ this._dc = null;
102
+ /** @type {AudioContext | null} */
103
+ this._ctx = null;
104
+ /** @type {MediaStreamAudioSourceNode | null} */
105
+ this._micSrc = null;
106
+ /** @type {MediaStreamAudioSourceNode | null} */
107
+ this._remoteSrc = null;
108
+ /** @type {HTMLAudioElement | null} Muted sink for the Chrome quirk: a
109
+ * remote WebRTC track stays silent in WebAudio unless the stream is ALSO
110
+ * attached to an <audio> element. Never added to the DOM. */
111
+ this._quirkAudio = null;
112
+ /** @type {AnalyserNode | null} */
113
+ this._micAnalyser = null;
114
+ /** @type {AnalyserNode | null} */
115
+ this._outAnalyser = null;
116
+ /** @type {OrbVisualiser | null} */
117
+ this._visualiser = null;
118
+ /** @type {number} Level/status poll timer (setInterval id). */
119
+ this._levelTimer = 0;
120
+ /** @type {Uint8Array} Scratch buffer for time-domain RMS reads. */
121
+ this._levelBuf = new Uint8Array(VIS_FFT_SIZE);
122
+ /** @type {number} Last time the output RMS was above the speaking gate. */
123
+ this._lastAudibleAt = 0;
124
+ /** @type {RtcStatus} */
125
+ this._status = "idle";
126
+ this._aiSpeaking = false;
127
+ /** @type {string} The response currently holding the backend slot (from
128
+ * response.created), so RMS-detected audio can be attributed to it. */
129
+ this._activeResponseId = "";
130
+ /** @type {Set<string>} response_ids that audibly played, so the UI can
131
+ * tell a barge-in cut (keep it) from a never-heard speculative response
132
+ * (drop it). Attribution is by "RMS opened while this response was
133
+ * active" — sound can't be tied to a response id without audio events. */
134
+ this._audibleResponses = new Set();
135
+ /** @type {Map<string, string>} CURRENT assistant transcript segment per
136
+ * response, accumulated from streamed deltas (reset on segment done). */
137
+ this._asstTranscriptByResp = new Map();
138
+ /** @type {Map<string, string>} Completed segments per response, joined. */
139
+ this._asstFullByResp = new Map();
140
+ this._muted = false;
141
+ // ── Response lock ────────────────────────────────────────────────────
142
+ // Same scheme as the WS client: the backend allows ONE response in
143
+ // flight, so response.create is serialized. `_openResponses` counts
144
+ // confirmed-but-unfinished responses; `_createInFlight` covers the window
145
+ // between our create and its response.created echo; extra creates queue.
146
+ this._openResponses = 0;
147
+ this._createInFlight = false;
148
+ /** @type {{ image?: string }[]} */
149
+ this._createQueue = [];
150
+ this._sessionConfigured = false;
151
+ this._startupGreeting = options.startupGreeting?.trim() ?? "";
152
+ this._startupGreetingSent = false;
153
+ this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })();
154
+ }
155
+
156
+ get status() {
157
+ return this._status;
158
+ }
159
+
160
+ /** @param {RtcStatus} status */
161
+ _setStatus(status) {
162
+ if (this._status === status) return;
163
+ this._status = status;
164
+ this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
165
+ }
166
+
167
+ /** Full assistant transcript so far for a response: completed segments plus
168
+ * the in-progress one. @param {string} rid @returns {string} */
169
+ _asstDisplay(rid) {
170
+ const full = this._asstFullByResp.get(rid) || "";
171
+ const seg = this._asstTranscriptByResp.get(rid) || "";
172
+ if (!seg) return full;
173
+ return full ? `${full} ${seg}` : seg;
174
+ }
175
+
176
+ _markAudible() {
177
+ if (this._status === "ai-speaking") return;
178
+ if (this._status === "closed" || this._status === "error") return;
179
+ this._setStatus("ai-speaking");
180
+ }
181
+
182
+ /**
183
+ * Full handshake. Resolves once the data channel is open AND the audio
184
+ * graph is wired (mic track sending, remote track feeding the analysers).
185
+ * @returns {Promise<void>}
186
+ */
187
+ async connect() {
188
+ if (this._pc) throw new Error("Already connected");
189
+ this._setStatus("connecting");
190
+
191
+ if (!this.options.micStream && this._acquireMic) {
192
+ this.options.micStream = await this._acquireMic();
193
+ }
194
+ if (this._closed) throw _codedError("connect aborted", "aborted");
195
+
196
+ this._setupAudioGraph();
197
+
198
+ const pc = new RTCPeerConnection(
199
+ this.options.iceServers?.length ? { iceServers: this.options.iceServers } : {},
200
+ );
201
+ this._pc = pc;
202
+
203
+ pc.addEventListener("track", (e) => this._onRemoteTrack(e));
204
+ pc.addEventListener("connectionstatechange", () => this._onConnectionState());
205
+
206
+ const micTrack = this.options.micStream?.getAudioTracks()[0];
207
+ if (!micTrack) throw new Error("No microphone track available");
208
+ micTrack.enabled = !this._muted;
209
+ pc.addTrack(micTrack, /** @type {MediaStream} */ (this.options.micStream));
210
+
211
+ // The client opens the events channel (OpenAI convention); the server
212
+ // waits for it and ignores channels with any other label.
213
+ const dc = pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
214
+ this._dc = dc;
215
+ dc.addEventListener("message", (e) => this._onDcMessage(e.data));
216
+ dc.addEventListener("close", () => this._onDcClose());
217
+
218
+ await pc.setLocalDescription(await pc.createOffer());
219
+ // No trickle ICE over the one-shot HTTP handshake: the offer must carry
220
+ // our candidates, so wait for gathering (bounded — host candidates are
221
+ // near-instant, and a STUN timeout shouldn't stall the connect).
222
+ await this._waitIceGathering(pc);
223
+ if (this._closed) throw _codedError("connect aborted", "aborted");
224
+
225
+ const answerSdp = await this._postOffer(pc.localDescription?.sdp ?? "");
226
+ if (this._closed) throw _codedError("connect aborted", "aborted");
227
+ await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
228
+
229
+ await this._waitDataChannelOpen(dc);
230
+ this._startLevelLoop();
231
+ // The server pushes session.created once it sees the channel open; the
232
+ // session.update reply is sent from that handler (mirrors the WS client).
233
+ }
234
+
235
+ /** @param {RTCPeerConnection} pc */
236
+ _waitIceGathering(pc) {
237
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
238
+ return new Promise((resolve) => {
239
+ const done = () => {
240
+ pc.removeEventListener("icegatheringstatechange", check);
241
+ clearTimeout(timer);
242
+ resolve(undefined);
243
+ };
244
+ const check = () => {
245
+ if (pc.iceGatheringState === "complete") done();
246
+ };
247
+ const timer = setTimeout(() => {
248
+ console.warn("[rtc] ICE gathering timed out; sending offer with partial candidates");
249
+ done();
250
+ }, ICE_GATHERING_TIMEOUT_MS);
251
+ pc.addEventListener("icegatheringstatechange", check);
252
+ });
253
+ }
254
+
255
+ /**
256
+ * POST the SDP offer, return the answer SDP. Non-2xx bodies are surfaced as
257
+ * errors ("all slots in use" arrives as a JSON error event with a 503).
258
+ * @param {string} offerSdp @returns {Promise<string>}
259
+ */
260
+ async _postOffer(offerSdp) {
261
+ console.log("[rtc] POST", this.options.callsUrl);
262
+ const response = await fetch(this.options.callsUrl, {
263
+ method: "POST",
264
+ headers: { "Content-Type": "application/sdp" },
265
+ body: offerSdp,
266
+ });
267
+ if (!response.ok) {
268
+ const text = await response.text().catch(() => "");
269
+ if (response.status === 503) {
270
+ // The s2s server rejects with a JSON error event when the pool is full.
271
+ try {
272
+ const j = JSON.parse(text);
273
+ const msg = j?.error?.message;
274
+ if (msg) throw _codedError(msg, "busy");
275
+ } catch (err) {
276
+ if (err instanceof Error && /** @type {any} */ (err).code === "busy") throw err;
277
+ }
278
+ throw _codedError("The speech service is busy — try again shortly.", "busy");
279
+ }
280
+ throw new Error(`WebRTC handshake failed (${response.status}): ${text.slice(0, 200)}`);
281
+ }
282
+ return response.text();
283
+ }
284
+
285
+ /** @param {RTCDataChannel} dc */
286
+ _waitDataChannelOpen(dc) {
287
+ if (dc.readyState === "open") return Promise.resolve();
288
+ return new Promise((resolve, reject) => {
289
+ const cleanup = () => {
290
+ clearTimeout(timer);
291
+ dc.removeEventListener("open", onOpen);
292
+ dc.removeEventListener("close", onClose);
293
+ dc.removeEventListener("error", onClose);
294
+ };
295
+ const onOpen = () => {
296
+ cleanup();
297
+ resolve(undefined);
298
+ };
299
+ const onClose = () => {
300
+ cleanup();
301
+ reject(new Error("WebRTC data channel closed before opening"));
302
+ };
303
+ const timer = setTimeout(() => {
304
+ cleanup();
305
+ reject(new Error(
306
+ "WebRTC connection timed out. If the server is remote, it may need a STUN/TURN server (RTC_ICE_SERVERS).",
307
+ ));
308
+ }, DC_OPEN_TIMEOUT_MS);
309
+ dc.addEventListener("open", onOpen);
310
+ dc.addEventListener("close", onClose);
311
+ dc.addEventListener("error", onClose);
312
+ });
313
+ }
314
+
315
+ // ── Audio graph ───────────────────────────────────────────────────────────
316
+ // Mic: micStream ─→ micAnalyser (orb bars + level meter; the track itself
317
+ // goes to the peer connection untouched).
318
+ // Output: remote track ─→ remoteSrc ─→ outAnalyser ─→ destination, plus the
319
+ // hidden muted <audio> that makes Chrome actually run the track.
320
+
321
+ _setupAudioGraph() {
322
+ const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" });
323
+ this._ctx = ctx;
324
+ if (ctx.state === "suspended") {
325
+ // Best-effort here — on iOS the resume that counts happened in the tap.
326
+ void ctx.resume().catch((err) => console.warn("[rtc] AudioContext resume failed:", err));
327
+ }
328
+
329
+ const micAnalyser = ctx.createAnalyser();
330
+ micAnalyser.fftSize = VIS_FFT_SIZE;
331
+ micAnalyser.smoothingTimeConstant = 0;
332
+ const micSrc = ctx.createMediaStreamSource(/** @type {MediaStream} */ (this.options.micStream));
333
+ micSrc.connect(micAnalyser);
334
+ this._micSrc = micSrc;
335
+ this._micAnalyser = micAnalyser;
336
+
337
+ const outAnalyser = ctx.createAnalyser();
338
+ outAnalyser.fftSize = VIS_FFT_SIZE;
339
+ outAnalyser.smoothingTimeConstant = 0.3;
340
+ outAnalyser.connect(ctx.destination);
341
+ this._outAnalyser = outAnalyser;
342
+
343
+ void this.setAudioOutputDevice(this.options.audioOutputId || "");
344
+
345
+ this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
346
+ this._visualiser.start();
347
+ }
348
+
349
+ /**
350
+ * Route Web Audio playback to a specific output device (Chrome/Edge).
351
+ * @param {string} [deviceId]
352
+ * @returns {Promise<boolean>}
353
+ */
354
+ async setAudioOutputDevice(deviceId = "") {
355
+ const ctx = this._ctx;
356
+ if (!ctx || typeof /** @type {any} */ (ctx).setSinkId !== "function") return false;
357
+ try {
358
+ await /** @type {any} */ (ctx).setSinkId(deviceId || "");
359
+ return true;
360
+ } catch (err) {
361
+ console.warn("[rtc] setSinkId failed:", err);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /** @param {RTCTrackEvent} e */
367
+ _onRemoteTrack(e) {
368
+ if (e.track.kind !== "audio" || !this._ctx || !this._outAnalyser) return;
369
+ const stream = e.streams[0] ?? new MediaStream([e.track]);
370
+ // Chrome quirk: without an <audio> sink the remote track produces silence
371
+ // in WebAudio. Muted so playback happens once, through the analyser path.
372
+ const quirk = new Audio();
373
+ quirk.muted = true;
374
+ quirk.srcObject = stream;
375
+ this._quirkAudio = quirk;
376
+ this._remoteSrc?.disconnect();
377
+ this._remoteSrc = this._ctx.createMediaStreamSource(stream);
378
+ this._remoteSrc.connect(this._outAnalyser);
379
+ if (this._debug) console.debug("[rtc] remote audio track attached");
380
+ }
381
+
382
+ // ── Output-RMS status + mic level ─────────────────────────────────────────
383
+
384
+ _startLevelLoop() {
385
+ if (this._levelTimer) return;
386
+ this._levelTimer = window.setInterval(() => this._pollLevels(), LEVEL_POLL_MS);
387
+ }
388
+
389
+ /** RMS (0..1) of an analyser's current time-domain buffer.
390
+ * @param {AnalyserNode} analyser */
391
+ _rmsOf(analyser) {
392
+ analyser.getByteTimeDomainData(this._levelBuf);
393
+ let sum = 0;
394
+ for (let i = 0; i < this._levelBuf.length; i++) {
395
+ const s = (this._levelBuf[i] - 128) / 128;
396
+ sum += s * s;
397
+ }
398
+ return Math.sqrt(sum / this._levelBuf.length);
399
+ }
400
+
401
+ _pollLevels() {
402
+ if (!this._micAnalyser || !this._outAnalyser) return;
403
+
404
+ // Mic level for the Settings meter / gate arc (raw, pre-mute).
405
+ this.dispatchEvent(
406
+ new CustomEvent("input-level", { detail: { rms: this._rmsOf(this._micAnalyser) } }),
407
+ );
408
+
409
+ // Speaking gate on the output: RMS opens the state; `response.done` and
410
+ // `speech_started` (the protocol's authoritative signals) close it.
411
+ const rms = this._rmsOf(this._outAnalyser);
412
+ const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
413
+ const now = performance.now();
414
+ if (db > SPEAKING_OPEN_DB) this._lastAudibleAt = now;
415
+ const audible = now - this._lastAudibleAt < SPEAKING_HANG_MS;
416
+ if (audible && !this._aiSpeaking) {
417
+ this._aiSpeaking = true;
418
+ if (this._activeResponseId) this._audibleResponses.add(this._activeResponseId);
419
+ this._markAudible();
420
+ }
421
+ }
422
+
423
+ // ── Data channel protocol ─────────────────────────────────────────────────
424
+
425
+ /** @param {unknown} raw */
426
+ _onDcMessage(raw) {
427
+ if (typeof raw !== "string") return;
428
+ let event;
429
+ try {
430
+ event = JSON.parse(raw);
431
+ } catch {
432
+ return;
433
+ }
434
+
435
+ const type = event?.type;
436
+ if (typeof type !== "string") return;
437
+ if (this._debug) {
438
+ const extra = type.startsWith("conversation.item.input_audio_transcription")
439
+ ? ` item=${event.item_id} ${event.delta ?? event.transcript ?? ""}`
440
+ : type.startsWith("response.")
441
+ ? ` resp=${event.response_id ?? event.response?.id ?? ""} status=${event.response?.status ?? ""}`
442
+ : "";
443
+ console.debug(`[rtc] ${type}${extra}`);
444
+ }
445
+
446
+ switch (type) {
447
+ case "session.created":
448
+ // Server-side defaults are already what we want; push only the
449
+ // user-tunable bits (voice, instructions, tools) — same as WS.
450
+ this._sendSessionUpdate();
451
+ this._sessionConfigured = true;
452
+ // Data-channel messages are ordered, so the hidden item and
453
+ // response.create are handled after the session.update above.
454
+ this._sendStartupGreeting();
455
+ if (this._status === "connecting") this._setStatus("connected");
456
+ break;
457
+
458
+ case "session.updated":
459
+ break;
460
+
461
+ case "input_audio_buffer.speech_started":
462
+ // Barge-in: unlike WS there is no client playback buffer to clear —
463
+ // the server flushes its track buffer — so this is UI state only.
464
+ this._aiSpeaking = false;
465
+ this._lastAudibleAt = 0;
466
+ this._setStatus("user-speaking");
467
+ break;
468
+
469
+ case "input_audio_buffer.speech_stopped":
470
+ if (this._status === "user-speaking") this._setStatus("processing");
471
+ break;
472
+
473
+ case "response.created":
474
+ this._openResponses++;
475
+ this._createInFlight = false;
476
+ this._activeResponseId = event.response?.id ?? "";
477
+ if (this._status === "connected" || this._status === "user-speaking") {
478
+ this._setStatus("processing");
479
+ }
480
+ break;
481
+
482
+ case "response.output_item.added":
483
+ if (this._status === "connected" || this._status === "user-speaking") {
484
+ this._setStatus("processing");
485
+ }
486
+ break;
487
+
488
+ case "response.done": {
489
+ this._aiSpeaking = false;
490
+ this._lastAudibleAt = 0;
491
+ this._openResponses = Math.max(0, this._openResponses - 1);
492
+ if (this._status === "ai-speaking" || this._status === "processing") {
493
+ this._setStatus("connected");
494
+ }
495
+ // Completion AND cancellation both arrive as response.done (status
496
+ // "cancelled") — same contract the WS client documents.
497
+ const status = event.response?.status ?? "completed";
498
+ const responseId = event.response?.id ?? "";
499
+ if (this._activeResponseId === responseId) this._activeResponseId = "";
500
+ const audible = responseId ? this._audibleResponses.has(responseId) : false;
501
+ this._audibleResponses.delete(responseId);
502
+ const transcript =
503
+ extractResponseTranscript(event.response) ||
504
+ this._asstDisplay(responseId) ||
505
+ "";
506
+ this._asstTranscriptByResp.delete(responseId);
507
+ this._asstFullByResp.delete(responseId);
508
+ this.dispatchEvent(new CustomEvent("response-finished", {
509
+ detail: { responseId, status, audible, transcript },
510
+ }));
511
+ this._flushQueuedCreate();
512
+ break;
513
+ }
514
+
515
+ case "response.function_call_arguments.done": {
516
+ const name = typeof event.name === "string" ? event.name : "";
517
+ const args = typeof event.arguments === "string" ? event.arguments : "{}";
518
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
519
+ if (name) {
520
+ this.dispatchEvent(new CustomEvent("toolcall", {
521
+ detail: { name, arguments: args, callId },
522
+ }));
523
+ } else {
524
+ console.warn(`[rtc] function_call_arguments.done with no name (call_id=${callId}); cannot run tool — turn may stall`);
525
+ }
526
+ break;
527
+ }
528
+
529
+ case "conversation.item.input_audio_transcription.delta": {
530
+ const delta = typeof event.delta === "string" ? event.delta : "";
531
+ if (delta) {
532
+ // The delta carries the full cumulative transcript so far; itemId is
533
+ // reused across a speculative continuation so the UI groups them.
534
+ this.dispatchEvent(
535
+ new CustomEvent("transcript", {
536
+ detail: {
537
+ role: "user",
538
+ text: delta,
539
+ partial: true,
540
+ itemId: typeof event.item_id === "string" ? event.item_id : "",
541
+ },
542
+ }),
543
+ );
544
+ }
545
+ break;
546
+ }
547
+
548
+ case "conversation.item.input_audio_transcription.completed": {
549
+ const transcript = typeof event.transcript === "string" ? event.transcript : "";
550
+ if (transcript) {
551
+ this.dispatchEvent(
552
+ new CustomEvent("transcript", {
553
+ detail: {
554
+ role: "user",
555
+ text: transcript,
556
+ partial: false,
557
+ itemId: typeof event.item_id === "string" ? event.item_id : "",
558
+ },
559
+ }),
560
+ );
561
+ }
562
+ break;
563
+ }
564
+
565
+ case "response.audio_transcript.delta":
566
+ case "response.output_audio_transcript.delta": {
567
+ this._markAudible();
568
+ const rid = typeof event.response_id === "string" ? event.response_id : "";
569
+ const delta = typeof event.delta === "string" ? event.delta : "";
570
+ if (delta) {
571
+ this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
572
+ this.dispatchEvent(
573
+ new CustomEvent("transcript", {
574
+ detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
575
+ }),
576
+ );
577
+ }
578
+ break;
579
+ }
580
+
581
+ case "response.audio_transcript.done":
582
+ case "response.output_audio_transcript.done": {
583
+ const rid = typeof event.response_id === "string" ? event.response_id : "";
584
+ // ONE completed segment; a response can emit several — concatenate
585
+ // until response.done clears the accumulator.
586
+ const segment =
587
+ (typeof event.transcript === "string" && event.transcript) ||
588
+ this._asstTranscriptByResp.get(rid) ||
589
+ "";
590
+ this._asstTranscriptByResp.delete(rid);
591
+ if (segment) {
592
+ const prev = this._asstFullByResp.get(rid) || "";
593
+ this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
594
+ }
595
+ const full = this._asstFullByResp.get(rid) || "";
596
+ if (full) {
597
+ this.dispatchEvent(
598
+ new CustomEvent("transcript", {
599
+ detail: { role: "assistant", text: full, partial: false, responseId: rid },
600
+ }),
601
+ );
602
+ }
603
+ break;
604
+ }
605
+
606
+ case "error": {
607
+ const err = event.error;
608
+ console.error("[rtc] server error:", err);
609
+ // Our optimistic create collided with a still-running response: clear
610
+ // the guard and re-queue for the next response.done (never retried
611
+ // immediately — that would just collide again).
612
+ if (err?.type === "conversation_already_has_active_response" ||
613
+ err?.code === "conversation_already_has_active_response") {
614
+ if (this._createInFlight) {
615
+ this._createInFlight = false;
616
+ this._createQueue.push({});
617
+ }
618
+ break;
619
+ }
620
+ // Every other server error is non-fatal: surface for logging, keep
621
+ // the session alive. Transport failures come through their own paths.
622
+ this.dispatchEvent(
623
+ new CustomEvent("server-error", { detail: { error: new Error(err?.message ?? "Server error") } }),
624
+ );
625
+ break;
626
+ }
627
+ }
628
+ }
629
+
630
+ _onDcClose() {
631
+ if (this._closed) return;
632
+ if (this._status === "closed" || this._status === "error") return;
633
+ console.log("[rtc] data channel closed by server");
634
+ this.dispatchEvent(
635
+ new CustomEvent("error", { detail: { error: new Error("Connection closed by the server") } }),
636
+ );
637
+ this._setStatus("error");
638
+ }
639
+
640
+ _onConnectionState() {
641
+ const state = this._pc?.connectionState;
642
+ if (this._debug) console.debug(`[rtc] connection state: ${state}`);
643
+ if (this._closed) return;
644
+ if (state === "disconnected") {
645
+ // Transient: browsers fire this on brief packet loss and usually recover
646
+ // to "connected" on their own; the terminal state is "failed", which
647
+ // fires this handler again if recovery doesn't happen.
648
+ console.warn("[rtc] connection disconnected — waiting for recovery or failure");
649
+ return;
650
+ }
651
+ if (state === "failed") {
652
+ if (this._status === "closed" || this._status === "error") return;
653
+ this.dispatchEvent(
654
+ new CustomEvent("error", { detail: { error: new Error(`WebRTC connection ${state}`) } }),
655
+ );
656
+ this._setStatus("error");
657
+ }
658
+ }
659
+
660
+ _sendSessionUpdate() {
661
+ // Minimal payload, exactly like the WS client: the server's pydantic
662
+ // validator rejects the whole event on unknown sub-field shapes.
663
+ /** @type {Record<string, any>} */
664
+ const session = {
665
+ type: "realtime",
666
+ instructions: this.options.instructions,
667
+ audio: {
668
+ output: { voice: this.options.voice },
669
+ },
670
+ };
671
+ if (this._tools.length) {
672
+ session.tools = this._tools;
673
+ session.tool_choice = "auto";
674
+ }
675
+ this._send({ type: "session.update", session });
676
+ }
677
+
678
+ /** Update voice/instructions on a live session without tearing down.
679
+ * @param {{ voice?: string; instructions?: string }} patch */
680
+ updateSession(patch) {
681
+ /** @type {Record<string, any>} */
682
+ const session = { type: "realtime" };
683
+ if (patch.instructions) session.instructions = patch.instructions;
684
+ if (patch.voice) session.audio = { output: { voice: patch.voice } };
685
+ if (Object.keys(session).length > 1) {
686
+ this._send({ type: "session.update", session });
687
+ }
688
+ }
689
+
690
+ /** Replace the declared tool set on a live session. Always sends `tools` —
691
+ * an empty array clears them. @param {import("../ws/s2s-ws-client.js").ToolDef[]} tools */
692
+ setTools(tools) {
693
+ this._tools = tools;
694
+ this._send({
695
+ type: "session.update",
696
+ session: { type: "realtime", tools, tool_choice: tools.length ? "auto" : "none" },
697
+ });
698
+ }
699
+
700
+ /** Return a tool's result to the model. @param {string} callId @param {string} output */
701
+ sendToolOutput(callId, output) {
702
+ if (!callId) return;
703
+ this._send({
704
+ type: "conversation.item.create",
705
+ item: { type: "function_call_output", call_id: callId, output },
706
+ });
707
+ }
708
+
709
+ /** Add an image to the conversation as user content (camera tool). The
710
+ * caller keeps `dataUrl` under the data-channel message budget — SCTP
711
+ * messages above the negotiated max (64 KiB on aiortc) fail to send.
712
+ * @param {string} dataUrl */
713
+ sendUserImage(dataUrl) {
714
+ this._send({
715
+ type: "conversation.item.create",
716
+ item: {
717
+ type: "message",
718
+ role: "user",
719
+ content: [{ type: "input_image", image_url: dataUrl }],
720
+ },
721
+ });
722
+ }
723
+
724
+ /**
725
+ * Ask the model to open the conversation exactly once. The synthetic user
726
+ * prompt stays in conversation history, warming the prompt prefix reused by
727
+ * the first spoken turn.
728
+ */
729
+ _sendStartupGreeting() {
730
+ if (!this._startupGreeting || this._startupGreetingSent) return;
731
+ this._startupGreetingSent = true;
732
+ this._send({
733
+ type: "conversation.item.create",
734
+ item: {
735
+ type: "message",
736
+ role: "user",
737
+ content: [{ type: "input_text", text: this._startupGreeting }],
738
+ },
739
+ });
740
+ this.requestResponse();
741
+ if (this._debug) console.debug("[rtc] startup greeting queued");
742
+ }
743
+
744
+ /** Ask the model to respond now (after tool results). Serialized behind the
745
+ * single-response slot, same as the WS client.
746
+ * @param {{ image?: string }} [opts] */
747
+ requestResponse(opts = {}) {
748
+ if (this._responseActive()) {
749
+ this._createQueue.push(opts);
750
+ if (this._debug) console.debug(`[rtc] response.create queued; pending=${this._createQueue.length}`);
751
+ return;
752
+ }
753
+ this._createResponseNow(opts);
754
+ }
755
+
756
+ _responseActive() {
757
+ return this._openResponses > 0 || this._createInFlight;
758
+ }
759
+
760
+ /** @param {{ image?: string }} [opts] */
761
+ _createResponseNow(opts = {}) {
762
+ if (!this._dc || this._dc.readyState !== "open") return;
763
+ if (opts.image) this.sendUserImage(opts.image);
764
+ this._createInFlight = true;
765
+ this._send({ type: "response.create" });
766
+ }
767
+
768
+ _flushQueuedCreate() {
769
+ if (this._createQueue.length > 0 && !this._responseActive()) {
770
+ const opts = this._createQueue.shift();
771
+ if (this._debug) console.debug(`[rtc] replaying queued response.create; remaining=${this._createQueue.length}`);
772
+ this._createResponseNow(opts);
773
+ }
774
+ }
775
+
776
+ /** @param {boolean} muted */
777
+ setMuted(muted) {
778
+ this._muted = muted;
779
+ // The caller (main.js) also toggles the shared micStream tracks; doing it
780
+ // here too keeps the client correct when driven standalone. A disabled
781
+ // track makes the browser transmit silence — the server VAD stays quiet.
782
+ for (const track of this.options.micStream?.getAudioTracks() ?? []) {
783
+ track.enabled = !muted;
784
+ }
785
+ }
786
+
787
+ /** Noise gate is a WebSocket-transport feature (implemented in its capture
788
+ * worklet); the WebRTC mic path sends the raw track, so this is a no-op.
789
+ * @param {import("../ws/s2s-ws-client.js").NoiseGate} _gate */
790
+ setNoiseGate(_gate) {}
791
+
792
+ /** Queue "join" gate — LB-mode only, which WebRTC doesn't support. Present
793
+ * so the two clients keep the same surface for the caller. */
794
+ join() {}
795
+
796
+ /** @param {Record<string, unknown>} event */
797
+ _send(event) {
798
+ if (!this._dc || this._dc.readyState !== "open") return;
799
+ try {
800
+ this._dc.send(JSON.stringify(event));
801
+ } catch (err) {
802
+ // A message over the SCTP limit (or a racing close) lands here; keep
803
+ // the session alive and let the caller's flow recover.
804
+ console.error("[rtc] data channel send failed:", err);
805
+ }
806
+ }
807
+
808
+ async close() {
809
+ this._closed = true;
810
+ if (this._levelTimer) {
811
+ clearInterval(this._levelTimer);
812
+ this._levelTimer = 0;
813
+ }
814
+ this._visualiser?.stop();
815
+ this._visualiser = null;
816
+ try {
817
+ this._dc?.close();
818
+ } catch {
819
+ // ignored
820
+ }
821
+ this._dc = null;
822
+ try {
823
+ this._pc?.close();
824
+ } catch {
825
+ // ignored
826
+ }
827
+ this._pc = null;
828
+ if (this._quirkAudio) {
829
+ this._quirkAudio.srcObject = null;
830
+ this._quirkAudio = null;
831
+ }
832
+ try {
833
+ this._remoteSrc?.disconnect();
834
+ } catch {
835
+ // ignored
836
+ }
837
+ try {
838
+ this._micSrc?.disconnect();
839
+ } catch {
840
+ // ignored
841
+ }
842
+ try {
843
+ this._micAnalyser?.disconnect();
844
+ } catch {
845
+ // ignored
846
+ }
847
+ try {
848
+ this._outAnalyser?.disconnect();
849
+ } catch {
850
+ // ignored
851
+ }
852
+ try {
853
+ await this._ctx?.close();
854
+ } catch {
855
+ // ignored
856
+ }
857
+ this._ctx = null;
858
+ this._remoteSrc = null;
859
+ this._micSrc = null;
860
+ this._micAnalyser = null;
861
+ this._outAnalyser = null;
862
+ this._setStatus("closed");
863
+ }
864
+ }
server.py CHANGED
@@ -23,9 +23,10 @@ disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
23
  browser connects directly to that URL, shown read-only in Settings.
24
 
25
  Endpoints:
26
- GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
27
  GET /api/me -> login + tier + remaining budget (LB mode only)
28
  POST /api/search -> { results, answer } Google via Serper.dev
 
29
  POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
30
  GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim
31
  DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
@@ -41,11 +42,13 @@ the moment a slot is actually claimed (a grant), never while queued.
41
  """
42
 
43
  import asyncio
 
44
  import logging
45
  import os
 
46
 
47
  import httpx
48
- from fastapi import FastAPI, HTTPException, Request
49
  from fastapi.responses import JSONResponse
50
  from fastapi.staticfiles import StaticFiles
51
  from pydantic import BaseModel
@@ -77,6 +80,48 @@ if SPEECH_TO_SPEECH_URL:
77
  # but nothing is metered: no budget, no reservations, no sign-in gating.
78
  SPACE_ID = os.environ.get("SPACE_ID", "").strip()
79
  LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  SERPER_URL = "https://google.serper.dev/search"
81
  # Cap results so the tool output stays small enough to feed back to the model.
82
  MAX_RESULTS = 5
@@ -128,6 +173,11 @@ def config():
128
  # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
129
  # browser dials it itself, and Settings shows it locked.
130
  "s2sUrl": SPEECH_TO_SPEECH_URL,
 
 
 
 
 
131
  "auth": AUTH_ENABLED,
132
  }
133
 
@@ -215,6 +265,46 @@ async def search(req: SearchRequest):
215
  return JSONResponse({"query": query, "answer": answer, "results": results})
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  @app.post("/api/session")
219
  async def session(request: Request):
220
  """Proxy the session handshake to the load balancer, keeping its URL secret,
 
23
  browser connects directly to that URL, shown read-only in Settings.
24
 
25
  Endpoints:
26
+ GET /api/config -> { search, lb, allowDirect, s2sUrl, rtc, iceServers, auth }
27
  GET /api/me -> login + tier + remaining budget (LB mode only)
28
  POST /api/search -> { results, answer } Google via Serper.dev
29
+ POST /api/calls -> proxies the WebRTC SDP offer to <s2s>/v1/realtime/calls
30
  POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
31
  GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim
32
  DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
 
42
  """
43
 
44
  import asyncio
45
+ import json
46
  import logging
47
  import os
48
+ from urllib.parse import urlsplit, urlunsplit
49
 
50
  import httpx
51
+ from fastapi import FastAPI, HTTPException, Request, Response
52
  from fastapi.responses import JSONResponse
53
  from fastapi.staticfiles import StaticFiles
54
  from pydantic import BaseModel
 
80
  # but nothing is metered: no budget, no reservations, no sign-in gating.
81
  SPACE_ID = os.environ.get("SPACE_ID", "").strip()
82
  LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID)
83
+
84
+
85
+ def _parse_ice_servers(raw: str) -> list:
86
+ """ICE servers for the browser's RTCPeerConnection, from RTC_ICE_SERVERS.
87
+
88
+ Accepts a JSON list of RTCIceServer dicts (same format as the s2s
89
+ server's SPEECH_TO_SPEECH_ICE_SERVERS, e.g.
90
+ ``[{"urls": "turn:t.example.com", "username": "u", "credential": "c"}]``),
91
+ a single such dict, or a plain comma-separated list of STUN/TURN URLs.
92
+ Empty when unset — host candidates only, which is fine for local use."""
93
+ raw = raw.strip()
94
+ if not raw:
95
+ return []
96
+ try:
97
+ data = json.loads(raw)
98
+ if isinstance(data, list):
99
+ return data
100
+ if isinstance(data, dict):
101
+ return [data]
102
+ except ValueError:
103
+ pass
104
+ return [{"urls": u.strip()} for u in raw.split(",") if u.strip()]
105
+
106
+
107
+ RTC_ICE_SERVERS = _parse_ice_servers(os.environ.get("RTC_ICE_SERVERS", ""))
108
+
109
+
110
+ def _webrtc_calls_url(s2s_url: str) -> str:
111
+ """Derive the WebRTC handshake URL from the pinned realtime URL.
112
+
113
+ ``ws://host:port/v1/realtime`` -> ``http://host:port/v1/realtime/calls``
114
+ (ws->http, wss->https; a bare host gets the default /v1/realtime path,
115
+ mirroring the client's buildDirectWsUrl normalisation)."""
116
+ s = s2s_url.strip()
117
+ if not s.startswith(("ws://", "wss://", "http://", "https://")):
118
+ s = "http://" + s
119
+ parts = urlsplit(s)
120
+ scheme = {"ws": "http", "wss": "https"}.get(parts.scheme, parts.scheme)
121
+ path = parts.path if parts.path not in ("", "/") else "/v1/realtime"
122
+ return urlunsplit((scheme, parts.netloc, path.rstrip("/") + "/calls", parts.query, ""))
123
+
124
+
125
  SERPER_URL = "https://google.serper.dev/search"
126
  # Cap results so the tool output stays small enough to feed back to the model.
127
  MAX_RESULTS = 5
 
173
  # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
174
  # browser dials it itself, and Settings shows it locked.
175
  "s2sUrl": SPEECH_TO_SPEECH_URL,
176
+ # WebRTC transport availability: the /api/calls proxy only forwards to
177
+ # the env-pinned URL (never a client-supplied one), so the toggle is
178
+ # offered exactly when that URL exists.
179
+ "rtc": bool(SPEECH_TO_SPEECH_URL),
180
+ "iceServers": RTC_ICE_SERVERS,
181
  "auth": AUTH_ENABLED,
182
  }
183
 
 
265
  return JSONResponse({"query": query, "answer": answer, "results": results})
266
 
267
 
268
+ @app.post("/api/calls")
269
+ async def calls(request: Request):
270
+ """Proxy the WebRTC SDP handshake to the pinned s2s server.
271
+
272
+ The browser can't POST /v1/realtime/calls cross-origin (the s2s server has
273
+ no CORS middleware, and an application/sdp POST is preflighted), so it
274
+ posts the offer here and we forward it server-side. Only the signaling hop
275
+ goes through this proxy — the negotiated audio/data-channel media flows
276
+ directly between the browser and the s2s server.
277
+
278
+ Deliberately forwards ONLY to SPEECH_TO_SPEECH_URL: honouring a
279
+ client-supplied target would make this an open proxy (SSRF). No env pin,
280
+ no WebRTC — the client keeps such setups on the WebSocket transport."""
281
+ if not SPEECH_TO_SPEECH_URL:
282
+ raise HTTPException(status_code=404, detail="Not found.")
283
+
284
+ offer = await request.body()
285
+ url = _webrtc_calls_url(SPEECH_TO_SPEECH_URL)
286
+ try:
287
+ # Generous timeout: the s2s server waits for its own ICE gathering
288
+ # (up to ~5 s) before returning the answer.
289
+ async with httpx.AsyncClient(timeout=15.0) as http:
290
+ resp = await http.post(url, headers={"Content-Type": "application/sdp"}, content=offer)
291
+ except httpx.RequestError as exc:
292
+ logger.warning("s2s calls endpoint unreachable: %r", exc)
293
+ raise HTTPException(status_code=502, detail="Speech service unreachable.")
294
+
295
+ # Relay the answer (or the error body) as-is; keep the Location header the
296
+ # s2s server sets on success (the call id, per the OpenAI GA contract).
297
+ headers = {}
298
+ if "location" in resp.headers:
299
+ headers["Location"] = resp.headers["location"]
300
+ return Response(
301
+ content=resp.content,
302
+ status_code=resp.status_code,
303
+ media_type=resp.headers.get("content-type", "application/sdp"),
304
+ headers=headers,
305
+ )
306
+
307
+
308
  @app.post("/api/session")
309
  async def session(request: Request):
310
  """Proxy the session handshake to the load balancer, keeping its URL secret,
style.css CHANGED
@@ -1009,6 +1009,9 @@ a:hover {
1009
  transition: opacity 0.25s ease;
1010
  }
1011
  .orb-wrap.live .mic-gate-arc { opacity: 1; }
 
 
 
1012
  .mga-track {
1013
  stroke: rgba(255, 255, 255, 0.1); /* hairline; recedes until needed */
1014
  stroke-width: 1.5;
 
1009
  transition: opacity 0.25s ease;
1010
  }
1011
  .orb-wrap.live .mic-gate-arc { opacity: 1; }
1012
+ /* The gate is a WebSocket-transport feature (it lives in the WS capture
1013
+ * worklet). During a WebRTC call only the mute button remains. */
1014
+ body.rtc-live .mic-gate-arc { display: none; }
1015
  .mga-track {
1016
  stroke: rgba(255, 255, 255, 0.1); /* hairline; recedes until needed */
1017
  stroke-width: 1.5;
ws/s2s-ws-client.js CHANGED
@@ -67,6 +67,8 @@
67
  * executes and replies via `sendToolOutput` + `requestResponse`.
68
  * @property {NoiseGate} [noiseGate] Client-side noise gate applied to the mic
69
  * before it's sent. Tunable live via `setNoiseGate`.
 
 
70
  *
71
  * @typedef {Object} NoiseGate
72
  * @property {boolean} enabled
@@ -530,10 +532,30 @@ export class S2sWsRealtimeClient extends EventTarget {
530
  this._outAnalyser = outAnalyser;
531
  this._playbackNode = playbackNode;
532
 
 
 
533
  this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
534
  this._visualiser.start();
535
  }
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  /** @param {string} connectUrl */
538
  _openWebSocket(connectUrl) {
539
  return new Promise((resolve, reject) => {
 
67
  * executes and replies via `sendToolOutput` + `requestResponse`.
68
  * @property {NoiseGate} [noiseGate] Client-side noise gate applied to the mic
69
  * before it's sent. Tunable live via `setNoiseGate`.
70
+ * @property {string} [audioOutputId] MediaDeviceInfo.deviceId for speakers.
71
+ * Applied via AudioContext.setSinkId when the browser supports it.
72
  *
73
  * @typedef {Object} NoiseGate
74
  * @property {boolean} enabled
 
532
  this._outAnalyser = outAnalyser;
533
  this._playbackNode = playbackNode;
534
 
535
+ await this.setAudioOutputDevice(this.options.audioOutputId || "");
536
+
537
  this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
538
  this._visualiser.start();
539
  }
540
 
541
+ /**
542
+ * Route Web Audio playback to a specific output device (Chrome/Edge).
543
+ * Empty string restores the system default.
544
+ * @param {string} [deviceId]
545
+ * @returns {Promise<boolean>}
546
+ */
547
+ async setAudioOutputDevice(deviceId = "") {
548
+ const ctx = this._ctx;
549
+ if (!ctx || typeof /** @type {any} */ (ctx).setSinkId !== "function") return false;
550
+ try {
551
+ await /** @type {any} */ (ctx).setSinkId(deviceId || "");
552
+ return true;
553
+ } catch (err) {
554
+ console.warn("[ws] setSinkId failed:", err);
555
+ return false;
556
+ }
557
+ }
558
+
559
  /** @param {string} connectUrl */
560
  _openWebSocket(connectUrl) {
561
  return new Promise((resolve, reject) => {