Sync Space with current realtime demo
Browse files- README.md +37 -32
- index.html +36 -1
- main.js +279 -32
- rtc/s2s-rtc-client.js +864 -0
- server.py +92 -2
- style.css +3 -0
- 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 |
-
#
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
backend
|
| 18 |
-
|
| 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 |
-
##
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
## Backend requirement
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 164 |
|
| 165 |
-
LocalStorage keys are namespaced `s2s.ws.*`
|
| 166 |
-
|
| 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)
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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**
|
| 5 |
*
|
| 6 |
-
* Click the orb -> we ask for the mic,
|
| 7 |
-
*
|
| 8 |
-
* audio
|
| 9 |
-
*
|
| 10 |
-
* ai-speaking).
|
| 11 |
*
|
| 12 |
-
* The
|
| 13 |
-
*
|
| 14 |
-
*
|
|
|
|
| 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 {
|
| 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 |
-
|
| 722 |
-
|
| 723 |
-
const
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1088 |
-
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1186 |
-
...target,
|
| 1187 |
voice: settings.voice,
|
| 1188 |
instructions: effectiveInstructions(),
|
| 1189 |
startupGreeting: STARTUP_GREETING_PROMPT,
|
| 1190 |
acquireMic: acquireMicStream,
|
| 1191 |
tools: activeToolDefs(),
|
| 1192 |
-
|
| 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) => {
|