andito HF Staff commited on
Commit
2418688
·
1 Parent(s): 1cfad64

Port authenticated voice session UX

Browse files
Files changed (10) hide show
  1. README.md +15 -1
  2. auth.py +13 -0
  3. index.html +18 -0
  4. main.js +169 -11
  5. server.py +68 -1
  6. style.css +72 -0
  7. ui/account.js +28 -2
  8. ui/chat.js +226 -15
  9. ws/s2s-ws-client.js +94 -1
  10. ws/user-audio-recorder.js +201 -0
README.md CHANGED
@@ -97,7 +97,11 @@ Three modes, picked by env (`/api/config` tells the client which one is active):
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
100
- never reaches the browser; the Settings URL field is hidden.
 
 
 
 
101
  - **Neither** — **Settings → Speech-to-speech server URL**: paste a full
102
  `connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
103
  (the app adds `/v1/realtime`), and the browser connects to it directly.
@@ -123,6 +127,8 @@ leaves the app unmetered. Tunable via env:
123
  |-----|---------|------|
124
  | `LIMIT_ANON_SEC` | `300` | Daily seconds for anonymous visitors (5 min) |
125
  | `LIMIT_FREE_SEC` | `600` | Daily seconds for signed-in non-PRO users (10 min) |
 
 
126
  | `UNLIMITED_ORGS` | _(adds to defaults)_ | Extra HF org names whose members get **unlimited** usage, like PRO |
127
  | `USAGE_HASH_SECRET` | _(random)_ | HMAC secret for hashing identity keys + signing the anon cookie |
128
 
@@ -155,6 +161,8 @@ Then open <http://localhost:7860/>, click the orb, allow the mic, talk.
155
  | Key | What |
156
  |-----|------|
157
  | Load balancer URL | Base URL of your S2S deployment. App POSTs `<lb>/session`. |
 
 
158
  | Voice | Qwen3-TTS speaker name (Aiden, Ryan, Dylan, Eric, Ono_Anna, Serena, Sohee, Uncle_Fu, Vivian) |
159
  | Instructions | System prompt sent in `session.update` once the WS opens |
160
 
@@ -173,12 +181,18 @@ NOT collide with the WebRTC variant.
173
  | `auth.py` | HF OAuth + per-request identity (tier, hashed keys) |
174
  | `limiter.py` | SQLite per-day talk-time budget (chunked server-clock reservation) |
175
  | `ws/s2s-ws-client.js` | WebSocket handshake + OpenAI Realtime GA protocol |
 
176
  | `ws/codec.js` | base64 <-> PCM helpers + transcript extraction (pure) |
177
  | `ws/orb-visualizer.js` | `OrbVisualiser`: FFT bands -> orb CSS custom properties |
178
  | `worklets/mic-capture.js` | AudioWorklet: 48 kHz Float32 -> 16 kHz Int16 PCM, posts ~40 ms chunks |
179
  | `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out |
180
  | `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) |
181
 
 
 
 
 
 
182
  ## Audio pipeline notes
183
 
184
  - **Input**: `getUserMedia({ echoCancellation, noiseSuppression, autoGainControl })`
 
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
100
+ never reaches the browser; the Settings URL field is hidden. When a visitor
101
+ signs in with Hugging Face, the proxy forwards their OAuth access token to the
102
+ allocator through `X-Reachy-Mini-Authorization`; the token remains
103
+ server-side. Set `REQUIRE_LOGIN=true` to reject anonymous allocation and ask
104
+ visitors to sign in before starting.
105
  - **Neither** — **Settings → Speech-to-speech server URL**: paste a full
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.
 
127
  |-----|---------|------|
128
  | `LIMIT_ANON_SEC` | `300` | Daily seconds for anonymous visitors (5 min) |
129
  | `LIMIT_FREE_SEC` | `600` | Daily seconds for signed-in non-PRO users (10 min) |
130
+ | `REQUIRE_LOGIN` | unset | Set to `true` to require HF sign-in before allocating or claiming a session |
131
+ | `STARTUP_GREETING` | one-sentence greeting prompt | Hidden prompt that opens the conversation and warms the model; set empty to disable |
132
  | `UNLIMITED_ORGS` | _(adds to defaults)_ | Extra HF org names whose members get **unlimited** usage, like PRO |
133
  | `USAGE_HASH_SECRET` | _(random)_ | HMAC secret for hashing identity keys + signing the anon cookie |
134
 
 
161
  | Key | What |
162
  |-----|------|
163
  | Load balancer URL | Base URL of your S2S deployment. App POSTs `<lb>/session`. |
164
+ | Microphone | Input device for capture. Applies on the next conversation or Restart. |
165
+ | Speakers | Assistant-audio output. Chrome/Edge can switch live; other browsers use 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 WS opens |
168
 
 
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
+ | `ws/user-audio-recorder.js` | Bounded browser-local PCM capture and WAV replay for user turns |
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 |
188
  | `worklets/audio-playback.js` | AudioWorklet: 24 kHz Float32 ring buffer -> 48 kHz, linear interp, fade in/out |
189
  | `style.css` | Orb animations, layout, dark theme (verbatim from the WebRTC app) |
190
 
191
+ The chat history keeps the exact post-gate audio sent over WebSocket and exposes
192
+ it through a local replay control. Backend VAD also drives an immediate
193
+ “Listening…” → “Sending voice…” bubble; when STT is enabled, the same bubble and
194
+ history row are updated with the transcript.
195
+
196
  ## Audio pipeline notes
197
 
198
  - **Input**: `getUserMedia({ echoCancellation, noiseSuppression, autoGainControl })`
auth.py CHANGED
@@ -106,6 +106,19 @@ def current_user(request):
106
  return _field(current_oauth(request), "user_info")
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def _user_org_names(user) -> "set[str]":
110
  """The user's organisations from the OAuth userinfo, by username/name/id."""
111
  names = set()
 
106
  return _field(current_oauth(request), "user_info")
107
 
108
 
109
+ def current_access_token(request) -> "str | None":
110
+ """The signed-in user's HF OAuth access token, or None.
111
+
112
+ Keep this server-side: it is used to attribute load-balancer session
113
+ requests to the signed-in HF account and must never be returned to the
114
+ browser.
115
+ """
116
+ token = _field(current_oauth(request), "access_token")
117
+ if token is None:
118
+ return None
119
+ return str(token).strip() or None
120
+
121
+
122
  def _user_org_names(user) -> "set[str]":
123
  """The user's organisations from the OAuth userinfo, by username/name/id."""
124
  names = set()
index.html CHANGED
@@ -258,6 +258,24 @@
258
  </label>
259
  </div>
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  <div class="field">
262
  <span class="field-head">
263
  Noise gate
 
258
  </label>
259
  </div>
260
 
261
+ <label class="field">
262
+ <span>Microphone</span>
263
+ <select id="audio-input">
264
+ <option value="">System default</option>
265
+ </select>
266
+ <small>Applies on the next conversation (or Restart).</small>
267
+ </label>
268
+
269
+ <label class="field" id="audio-output-field">
270
+ <span>Speakers</span>
271
+ <select id="audio-output">
272
+ <option value="">System default</option>
273
+ </select>
274
+ <small id="audio-output-hint">
275
+ Where assistant audio plays. Chrome/Edge support switching outputs.
276
+ </small>
277
+ </label>
278
+
279
  <div class="field">
280
  <span class="field-head">
281
  Noise gate
main.js CHANGED
@@ -44,6 +44,8 @@ const STORAGE_KEYS = {
44
  tools: "s2s.ws.tools",
45
  searchKey: "s2s.ws.searchKey",
46
  noiseGate: "s2s.ws.noiseGate",
 
 
47
  };
48
 
49
  // ── Noise gate ──────────────────────────────────────────────────────────────
@@ -102,6 +104,8 @@ function loadSettings() {
102
  voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
103
  instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
104
  noiseGate: loadGateThreshold(),
 
 
105
  };
106
  }
107
 
@@ -124,6 +128,8 @@ function saveSettings(s) {
124
  localStorage.setItem(STORAGE_KEYS.voice, s.voice);
125
  localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
126
  localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
 
 
127
  }
128
 
129
  /** @returns {{ web_search: boolean, camera_snapshot: boolean }} */
@@ -238,6 +244,12 @@ const connField = $("#conn-field");
238
  const connHint = $("#conn-hint");
239
  /** @type {HTMLSelectElement} */
240
  const inputVoice = $("#voice");
 
 
 
 
 
 
241
  /** @type {HTMLTextAreaElement} */
242
  const inputInstructions = $("#instructions");
243
  /** @type {HTMLInputElement} */
@@ -280,6 +292,9 @@ let allowDirect = true;
280
  // Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
281
  // mode: the field displays it read-only and the saved user URL is untouched.
282
  let pinnedUrl = "";
 
 
 
283
 
284
  // ── Tool state ──────────────────────────────────────────────────────────────
285
  let toolsEnabled = loadTools();
@@ -321,7 +336,13 @@ function pushToolsToSession() {
321
  // ── Chat view ───────────────────────────────────────────────────────────────
322
  // Owns the history panel, the ephemeral bubbles, and all transcript/tool
323
  // streaming state. The client's events are forwarded to its on* methods.
324
- const chat = new ChatView();
 
 
 
 
 
 
325
 
326
  // ── Account / limiter ─────────────────────────────────────────────────────
327
  // Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
@@ -329,6 +350,7 @@ const chat = new ChatView();
329
  // and tears down when the server reports the budget is spent.
330
  const account = new Account();
331
  let limiterOn = false;
 
332
  let heartbeatTimer = 0;
333
  let trackedSessionId = "";
334
  let trackedTier = "";
@@ -342,6 +364,15 @@ let client = null;
342
  let micStream = null;
343
  let micMuted = false;
344
 
 
 
 
 
 
 
 
 
 
345
  /** @param {AppState} next */
346
  function setState(next) {
347
  currentState = next;
@@ -408,6 +439,7 @@ function openSettings() {
408
  inputInstructions.value = settings.instructions;
409
  syncGateUi();
410
  updateRestartAvailability();
 
411
  settingsModal.showModal();
412
  }
413
 
@@ -837,6 +869,10 @@ async function fetchConfig() {
837
  allowDirect = json.allowDirect ?? !lbMode;
838
  // Deploy-pinned direct URL (overrides the LB server-side already).
839
  pinnedUrl = (json.s2sUrl || "").trim();
 
 
 
 
840
  // The conversation-time limiter rides on the LB being present.
841
  limiterOn = lbMode;
842
  }
@@ -846,7 +882,10 @@ async function fetchConfig() {
846
  }
847
  if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
848
  // Login chip + remaining-budget (no-op / hidden when the limiter is off).
849
- void account.refresh();
 
 
 
850
  syncToolsUi();
851
  syncConnectionUi();
852
  }
@@ -920,6 +959,8 @@ function readSettingsFromForm() {
920
  voice: inputVoice.value || DEFAULT_VOICE,
921
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
922
  noiseGate: readGateThreshold(),
 
 
923
  };
924
  }
925
 
@@ -978,9 +1019,12 @@ settingsForm.addEventListener("submit", (event) => {
978
  saveSettings(settings);
979
 
980
  // Voice + instructions can apply to a live session without reconnecting; a
981
- // changed connection URL only takes effect on the next restart.
 
 
982
  if (client && LIVE_STATES.has(currentState)) {
983
  client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() });
 
984
  }
985
  });
986
 
@@ -992,6 +1036,10 @@ inputNoiseGate.addEventListener("input", () => {
992
 
993
  restartBtn.addEventListener("click", async () => {
994
  if (currentState === "connecting") return; // a connect is already underway
 
 
 
 
995
  settings = readSettingsFromForm();
996
  saveSettings(settings);
997
  if (missingServerUrl()) { promptServerUrl(); return; } // keep settings open
@@ -1010,6 +1058,10 @@ restartBtn.addEventListener("click", async () => {
1010
  circleBtn.addEventListener("click", async () => {
1011
  try {
1012
  if (currentState === "idle" || currentState === "error") {
 
 
 
 
1013
  if (missingServerUrl()) { promptServerUrl(); return; }
1014
  await doStart();
1015
  }
@@ -1022,6 +1074,11 @@ circleBtn.addEventListener("click", async () => {
1022
  * a real fault (surface it). doStart already closed any orphan AudioContext.
1023
  * @param {any} err */
1024
  async function handleStartError(err) {
 
 
 
 
 
1025
  if (err && err.code === "limit") {
1026
  await teardown();
1027
  account.showLimit(err.tier);
@@ -1055,10 +1112,7 @@ async function handleStartError(err) {
1055
  micBtn.addEventListener("click", () => {
1056
  if (!micStream || !client) return;
1057
  micMuted = !micMuted;
1058
- for (const track of micStream.getAudioTracks()) {
1059
- track.enabled = !micMuted;
1060
- }
1061
- client.setMuted(micMuted);
1062
  micBtn.classList.toggle("muted", micMuted);
1063
  micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
1064
  micBtn.title = micMuted ? "Unmute" : "Mute";
@@ -1081,16 +1135,104 @@ joinQueueBtn.addEventListener("click", () => {
1081
  if (client) client.join();
1082
  });
1083
 
1084
- const MIC_CONSTRAINTS = {
1085
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
 
 
1086
  };
1087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1088
  /** Prompt for mic permission up front, then immediately release the tracks so no
1089
  * recording indicator lingers during a queue wait. Throws a friendly error if the
1090
  * user denies. */
1091
  async function primeMicPermission() {
1092
  try {
1093
- const s = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
1094
  for (const track of s.getTracks()) track.stop();
1095
  } catch (err) {
1096
  throw new Error(
@@ -1102,7 +1244,7 @@ async function primeMicPermission() {
1102
  /** Acquire the live capture stream once a slot is granted. Permission was primed
1103
  * in the tap gesture, so this is silent. Stored module-side for mute + teardown. */
1104
  async function acquireMicStream() {
1105
- micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
1106
  return micStream;
1107
  }
1108
 
@@ -1183,12 +1325,15 @@ async function doStart(audioContext = null) {
1183
  ...target,
1184
  voice: settings.voice,
1185
  instructions: effectiveInstructions(),
 
1186
  acquireMic: acquireMicStream,
1187
  tools: activeToolDefs(),
1188
  noiseGate: gateParams(settings.noiseGate),
 
1189
  ...(audioContext ? { audioContext } : {}),
1190
  });
1191
  client = c;
 
1192
 
1193
  c.addEventListener("queue", (e) => {
1194
  const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
@@ -1212,11 +1357,24 @@ async function doStart(audioContext = null) {
1212
  c.addEventListener("status", (e) => {
1213
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1214
  onClientStatus(detail.status);
 
1215
  });
1216
  c.addEventListener("transcript", (e) => {
1217
  const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
1218
  chat.onTranscript(d);
1219
  });
 
 
 
 
 
 
 
 
 
 
 
 
1220
 
1221
  c.addEventListener("response-finished", (e) => {
1222
  const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
 
44
  tools: "s2s.ws.tools",
45
  searchKey: "s2s.ws.searchKey",
46
  noiseGate: "s2s.ws.noiseGate",
47
+ audioInputId: "s2s.audio.inputId",
48
+ audioOutputId: "s2s.audio.outputId",
49
  };
50
 
51
  // ── Noise gate ──────────────────────────────────────────────────────────────
 
104
  voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
105
  instructions: localStorage.getItem(STORAGE_KEYS.instructions) || DEFAULT_INSTRUCTIONS,
106
  noiseGate: loadGateThreshold(),
107
+ audioInputId: localStorage.getItem(STORAGE_KEYS.audioInputId) || "",
108
+ audioOutputId: localStorage.getItem(STORAGE_KEYS.audioOutputId) || "",
109
  };
110
  }
111
 
 
128
  localStorage.setItem(STORAGE_KEYS.voice, s.voice);
129
  localStorage.setItem(STORAGE_KEYS.instructions, s.instructions);
130
  localStorage.setItem(STORAGE_KEYS.noiseGate, String(s.noiseGate));
131
+ localStorage.setItem(STORAGE_KEYS.audioInputId, s.audioInputId || "");
132
+ localStorage.setItem(STORAGE_KEYS.audioOutputId, s.audioOutputId || "");
133
  }
134
 
135
  /** @returns {{ web_search: boolean, camera_snapshot: boolean }} */
 
244
  const connHint = $("#conn-hint");
245
  /** @type {HTMLSelectElement} */
246
  const inputVoice = $("#voice");
247
+ /** @type {HTMLSelectElement} */
248
+ const inputAudioInput = $("#audio-input");
249
+ /** @type {HTMLSelectElement} */
250
+ const inputAudioOutput = $("#audio-output");
251
+ /** @type {HTMLElement} */
252
+ const audioOutputHint = $("#audio-output-hint");
253
  /** @type {HTMLTextAreaElement} */
254
  const inputInstructions = $("#instructions");
255
  /** @type {HTMLInputElement} */
 
292
  // Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
293
  // mode: the field displays it read-only and the saved user URL is untouched.
294
  let pinnedUrl = "";
295
+ // Optional hidden user prompt supplied by the deployment. When non-empty, the
296
+ // client asks the model to greet once after the initial session configuration.
297
+ let startupGreeting = "";
298
 
299
  // ── Tool state ──────────────────────────────────────────────────────────────
300
  let toolsEnabled = loadTools();
 
336
  // ── Chat view ───────────────────────────────────────────────────────────────
337
  // Owns the history panel, the ephemeral bubbles, and all transcript/tool
338
  // streaming state. The client's events are forwarded to its on* methods.
339
+ let userAudioReplaying = false;
340
+ const chat = new ChatView({
341
+ onUserAudioPlaybackChange(playing) {
342
+ userAudioReplaying = playing;
343
+ syncMicMuteState();
344
+ },
345
+ });
346
 
347
  // ── Account / limiter ─────────────────────────────────────────────────────
348
  // Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
 
350
  // and tears down when the server reports the budget is spent.
351
  const account = new Account();
352
  let limiterOn = false;
353
+ let loginRequired = false;
354
  let heartbeatTimer = 0;
355
  let trackedSessionId = "";
356
  let trackedTier = "";
 
364
  let micStream = null;
365
  let micMuted = false;
366
 
367
+ /** Apply both the user's mute choice and the temporary replay guard. */
368
+ function syncMicMuteState() {
369
+ const muted = micMuted || userAudioReplaying;
370
+ for (const track of micStream?.getAudioTracks() ?? []) {
371
+ track.enabled = !muted;
372
+ }
373
+ client?.setMuted(muted);
374
+ }
375
+
376
  /** @param {AppState} next */
377
  function setState(next) {
378
  currentState = next;
 
439
  inputInstructions.value = settings.instructions;
440
  syncGateUi();
441
  updateRestartAvailability();
442
+ void refreshAudioDeviceLists();
443
  settingsModal.showModal();
444
  }
445
 
 
869
  allowDirect = json.allowDirect ?? !lbMode;
870
  // Deploy-pinned direct URL (overrides the LB server-side already).
871
  pinnedUrl = (json.s2sUrl || "").trim();
872
+ startupGreeting = typeof json.startupGreeting === "string"
873
+ ? json.startupGreeting.trim()
874
+ : "";
875
+ loginRequired = !!json.requireLogin;
876
  // The conversation-time limiter rides on the LB being present.
877
  limiterOn = lbMode;
878
  }
 
882
  }
883
  if (DEBUG) console.debug(`[ui] config: allowDirect=${allowDirect} lbMode=${lbMode}`);
884
  // Login chip + remaining-budget (no-op / hidden when the limiter is off).
885
+ await account.refresh();
886
+ if (currentState === "idle" && loginRequired && !account.loggedIn) {
887
+ setCaption("Sign in to start");
888
+ }
889
  syncToolsUi();
890
  syncConnectionUi();
891
  }
 
959
  voice: inputVoice.value || DEFAULT_VOICE,
960
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
961
  noiseGate: readGateThreshold(),
962
+ audioInputId: inputAudioInput.value || "",
963
+ audioOutputId: inputAudioOutput.value || "",
964
  };
965
  }
966
 
 
1019
  saveSettings(settings);
1020
 
1021
  // Voice + instructions can apply to a live session without reconnecting; a
1022
+ // changed connection URL only takes effect on the next restart. Speaker
1023
+ // output can switch live when the browser supports AudioContext.setSinkId;
1024
+ // mic device changes need a Restart (new getUserMedia stream).
1025
  if (client && LIVE_STATES.has(currentState)) {
1026
  client.updateSession({ voice: settings.voice, instructions: effectiveInstructions() });
1027
+ void client.setAudioOutputDevice(settings.audioOutputId);
1028
  }
1029
  });
1030
 
 
1036
 
1037
  restartBtn.addEventListener("click", async () => {
1038
  if (currentState === "connecting") return; // a connect is already underway
1039
+ if (loginRequired && !account.loggedIn) {
1040
+ account.showLoginRequired();
1041
+ return;
1042
+ }
1043
  settings = readSettingsFromForm();
1044
  saveSettings(settings);
1045
  if (missingServerUrl()) { promptServerUrl(); return; } // keep settings open
 
1058
  circleBtn.addEventListener("click", async () => {
1059
  try {
1060
  if (currentState === "idle" || currentState === "error") {
1061
+ if (loginRequired && !account.loggedIn) {
1062
+ account.showLoginRequired();
1063
+ return;
1064
+ }
1065
  if (missingServerUrl()) { promptServerUrl(); return; }
1066
  await doStart();
1067
  }
 
1074
  * a real fault (surface it). doStart already closed any orphan AudioContext.
1075
  * @param {any} err */
1076
  async function handleStartError(err) {
1077
+ if (err && err.code === "login-required") {
1078
+ await teardown();
1079
+ account.showLoginRequired(err.loginUrl);
1080
+ return;
1081
+ }
1082
  if (err && err.code === "limit") {
1083
  await teardown();
1084
  account.showLimit(err.tier);
 
1112
  micBtn.addEventListener("click", () => {
1113
  if (!micStream || !client) return;
1114
  micMuted = !micMuted;
1115
+ syncMicMuteState();
 
 
 
1116
  micBtn.classList.toggle("muted", micMuted);
1117
  micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
1118
  micBtn.title = micMuted ? "Unmute" : "Mute";
 
1135
  if (client) client.join();
1136
  });
1137
 
1138
+ const MIC_CONSTRAINTS_BASE = {
1139
+ echoCancellation: true,
1140
+ noiseSuppression: true,
1141
+ autoGainControl: true,
1142
  };
1143
 
1144
+ /** @returns {MediaStreamConstraints} */
1145
+ function micConstraints() {
1146
+ /** @type {MediaTrackConstraints} */
1147
+ const audio = { ...MIC_CONSTRAINTS_BASE };
1148
+ if (settings.audioInputId) {
1149
+ // ideal (not exact): if the saved device was unplugged, fall back quietly.
1150
+ audio.deviceId = { ideal: settings.audioInputId };
1151
+ }
1152
+ return { audio };
1153
+ }
1154
+
1155
+ /** True when Web Audio can route playback to a chosen output device. */
1156
+ function supportsAudioOutputSelection() {
1157
+ const Ctx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext;
1158
+ return typeof Ctx?.prototype?.setSinkId === "function";
1159
+ }
1160
+
1161
+ /**
1162
+ * Rebuild the mic/speaker <select>s from enumerateDevices. Labels are blank
1163
+ * until mic permission has been granted at least once.
1164
+ */
1165
+ async function refreshAudioDeviceLists() {
1166
+ const canPickOutput = supportsAudioOutputSelection();
1167
+ inputAudioOutput.disabled = !canPickOutput;
1168
+ audioOutputHint.textContent = canPickOutput
1169
+ ? "Where assistant audio plays. Can change live while connected."
1170
+ : "Speaker selection needs a browser with AudioContext.setSinkId (Chrome/Edge).";
1171
+
1172
+ /** @type {MediaDeviceInfo[]} */
1173
+ let devices = [];
1174
+ try {
1175
+ devices = await navigator.mediaDevices.enumerateDevices();
1176
+ } catch (err) {
1177
+ console.warn("[main] enumerateDevices failed:", err);
1178
+ }
1179
+
1180
+ const inputs = devices.filter((d) => d.kind === "audioinput");
1181
+ const outputs = devices.filter((d) => d.kind === "audiooutput");
1182
+ const labelsReady = devices.some((d) => d.label);
1183
+
1184
+ fillDeviceSelect(inputAudioInput, inputs, settings.audioInputId, "Microphone");
1185
+ fillDeviceSelect(inputAudioOutput, outputs, settings.audioOutputId, "Speaker");
1186
+
1187
+ const hint = inputAudioInput.parentElement?.querySelector("small");
1188
+ if (hint) {
1189
+ hint.textContent = labelsReady
1190
+ ? "Applies on the next conversation (or Restart)."
1191
+ : "Allow microphone access (tap Start once) to see device names. Mic changes apply on Restart.";
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * @param {HTMLSelectElement} select
1197
+ * @param {MediaDeviceInfo[]} devices
1198
+ * @param {string} selectedId
1199
+ * @param {string} fallbackLabel
1200
+ */
1201
+ function fillDeviceSelect(select, devices, selectedId, fallbackLabel) {
1202
+ const prev = selectedId || select.value || "";
1203
+ select.replaceChildren();
1204
+ const def = document.createElement("option");
1205
+ def.value = "";
1206
+ def.textContent = "System default";
1207
+ select.appendChild(def);
1208
+ devices.forEach((d, i) => {
1209
+ const opt = document.createElement("option");
1210
+ opt.value = d.deviceId;
1211
+ opt.textContent = d.label || `${fallbackLabel} ${i + 1}`;
1212
+ select.appendChild(opt);
1213
+ });
1214
+ if (prev && ![...select.options].some((o) => o.value === prev)) {
1215
+ const missing = document.createElement("option");
1216
+ missing.value = prev;
1217
+ missing.textContent = `${fallbackLabel} (saved, not found)`;
1218
+ select.appendChild(missing);
1219
+ }
1220
+ select.value = prev;
1221
+ if (select.value !== prev) select.value = "";
1222
+ }
1223
+
1224
+ if (navigator.mediaDevices?.addEventListener) {
1225
+ navigator.mediaDevices.addEventListener("devicechange", () => {
1226
+ if (settingsModal.open) void refreshAudioDeviceLists();
1227
+ });
1228
+ }
1229
+
1230
  /** Prompt for mic permission up front, then immediately release the tracks so no
1231
  * recording indicator lingers during a queue wait. Throws a friendly error if the
1232
  * user denies. */
1233
  async function primeMicPermission() {
1234
  try {
1235
+ const s = await navigator.mediaDevices.getUserMedia(micConstraints());
1236
  for (const track of s.getTracks()) track.stop();
1237
  } catch (err) {
1238
  throw new Error(
 
1244
  /** Acquire the live capture stream once a slot is granted. Permission was primed
1245
  * in the tap gesture, so this is silent. Stored module-side for mute + teardown. */
1246
  async function acquireMicStream() {
1247
+ micStream = await navigator.mediaDevices.getUserMedia(micConstraints());
1248
  return micStream;
1249
  }
1250
 
 
1325
  ...target,
1326
  voice: settings.voice,
1327
  instructions: effectiveInstructions(),
1328
+ startupGreeting,
1329
  acquireMic: acquireMicStream,
1330
  tools: activeToolDefs(),
1331
  noiseGate: gateParams(settings.noiseGate),
1332
+ audioOutputId: settings.audioOutputId || "",
1333
  ...(audioContext ? { audioContext } : {}),
1334
  });
1335
  client = c;
1336
+ c.setMuted(micMuted || userAudioReplaying);
1337
 
1338
  c.addEventListener("queue", (e) => {
1339
  const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
 
1357
  c.addEventListener("status", (e) => {
1358
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1359
  onClientStatus(detail.status);
1360
+ if (detail.status === "ai-speaking") chat.onAssistantActivity();
1361
  });
1362
  c.addEventListener("transcript", (e) => {
1363
  const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
1364
  chat.onTranscript(d);
1365
  });
1366
+ c.addEventListener("user-turn-started", (e) => {
1367
+ const detail = /** @type {CustomEvent<{ itemId?: string }>} */ (e).detail;
1368
+ chat.onUserTurnStarted(detail);
1369
+ });
1370
+ c.addEventListener("user-turn-stopped", (e) => {
1371
+ const detail = /** @type {CustomEvent<{ itemId?: string }>} */ (e).detail;
1372
+ chat.onUserTurnStopped(detail);
1373
+ });
1374
+ c.addEventListener("user-audio", (e) => {
1375
+ const detail = /** @type {CustomEvent<{ itemId?: string; audio: Blob; durationMs?: number; truncated?: boolean }>} */ (e).detail;
1376
+ chat.onUserAudio(detail);
1377
+ });
1378
 
1379
  c.addEventListener("response-finished", (e) => {
1380
  const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
server.py CHANGED
@@ -77,10 +77,24 @@ 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
83
  HERE = os.path.dirname(os.path.abspath(__file__))
 
84
 
85
  app = FastAPI(title="s2s-demo")
86
 
@@ -127,7 +141,9 @@ def config():
127
  # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
128
  # browser dials it itself, and Settings shows it locked.
129
  "s2sUrl": SPEECH_TO_SPEECH_URL,
 
130
  "auth": AUTH_ENABLED,
 
131
  }
132
 
133
 
@@ -144,6 +160,7 @@ async def me(request: Request):
144
  out = {
145
  "enabled": True,
146
  "auth": AUTH_ENABLED,
 
147
  **view,
148
  "remainingSec": rem,
149
  "limitSec": limiter.budget_for(tier),
@@ -156,6 +173,26 @@ async def me(request: Request):
156
  return resp
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  @app.post("/api/search")
160
  async def search(req: SearchRequest):
161
  """Proxy a Google search via Serper.dev. The key stays on the server unless
@@ -230,6 +267,10 @@ async def session(request: Request):
230
  # never call this. 404 so it's indistinguishable from a missing route.
231
  raise HTTPException(status_code=404, detail="Not found.")
232
 
 
 
 
 
233
  tier, keys, set_cookie = auth.resolve_identity(request)
234
  # Metering runs only on the deployed Space; off-Space the LB still proxies but
235
  # nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
@@ -250,7 +291,11 @@ async def session(request: Request):
250
  url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
251
  try:
252
  async with httpx.AsyncClient(timeout=15.0) as http:
253
- lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
 
 
 
 
254
  except httpx.RequestError as exc:
255
  logger.warning("Load balancer unreachable: %r", exc)
256
  raise HTTPException(status_code=502, detail="Speech service unreachable.")
@@ -286,6 +331,24 @@ async def session(request: Request):
286
  return await _finalize_grant(data, keys, tier, tracked, set_cookie)
287
 
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  @app.get("/api/queue/{queue_id}")
290
  async def queue_status(queue_id: str, request: Request):
291
  """Poll a waiting ticket: relay the position, or — when the head of the line
@@ -294,6 +357,10 @@ async def queue_status(queue_id: str, request: Request):
294
  if not LOAD_BALANCER_URL:
295
  raise HTTPException(status_code=404, detail="Not found.")
296
 
 
 
 
 
297
  tier, keys, set_cookie = auth.resolve_identity(request)
298
  tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
299
 
 
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
+ # Optional deployment gate. When enabled, session allocation fails closed unless
81
+ # Hugging Face OAuth is available and the requester is signed in.
82
+ REQUIRE_LOGIN = os.environ.get("REQUIRE_LOGIN", "").strip().lower() in {
83
+ "1",
84
+ "true",
85
+ "yes",
86
+ "on",
87
+ }
88
+ DEFAULT_STARTUP_GREETING = (
89
+ "Start the conversation now with a brief, spontaneous greeting in character. "
90
+ "Keep it to one sentence, invite the user in naturally, and vary the wording each time."
91
+ )
92
+ STARTUP_GREETING = os.environ.get("STARTUP_GREETING", DEFAULT_STARTUP_GREETING).strip()
93
  SERPER_URL = "https://google.serper.dev/search"
94
  # Cap results so the tool output stays small enough to feed back to the model.
95
  MAX_RESULTS = 5
96
  HERE = os.path.dirname(os.path.abspath(__file__))
97
+ LB_USER_AGENT = "hf-realtime-voice-space"
98
 
99
  app = FastAPI(title="s2s-demo")
100
 
 
141
  # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
142
  # browser dials it itself, and Settings shows it locked.
143
  "s2sUrl": SPEECH_TO_SPEECH_URL,
144
+ "startupGreeting": STARTUP_GREETING,
145
  "auth": AUTH_ENABLED,
146
+ "requireLogin": REQUIRE_LOGIN,
147
  }
148
 
149
 
 
160
  out = {
161
  "enabled": True,
162
  "auth": AUTH_ENABLED,
163
+ "loginRequired": REQUIRE_LOGIN,
164
  **view,
165
  "remainingSec": rem,
166
  "limitSec": limiter.budget_for(tier),
 
173
  return resp
174
 
175
 
176
+ def _login_gate(request: Request) -> JSONResponse | None:
177
+ """Reject anonymous session use when this deployment requires HF login."""
178
+ if not REQUIRE_LOGIN:
179
+ return None
180
+ if not AUTH_ENABLED:
181
+ return JSONResponse(
182
+ {"reason": "auth_unavailable"},
183
+ status_code=503,
184
+ )
185
+ if auth.current_user(request) is None:
186
+ return JSONResponse(
187
+ {
188
+ "reason": "login_required",
189
+ "loginUrl": auth.OAUTH_LOGIN_PATH,
190
+ },
191
+ status_code=401,
192
+ )
193
+ return None
194
+
195
+
196
  @app.post("/api/search")
197
  async def search(req: SearchRequest):
198
  """Proxy a Google search via Serper.dev. The key stays on the server unless
 
267
  # never call this. 404 so it's indistinguishable from a missing route.
268
  raise HTTPException(status_code=404, detail="Not found.")
269
 
270
+ login_error = _login_gate(request)
271
+ if login_error is not None:
272
+ return login_error
273
+
274
  tier, keys, set_cookie = auth.resolve_identity(request)
275
  # Metering runs only on the deployed Space; off-Space the LB still proxies but
276
  # nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
 
291
  url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
292
  try:
293
  async with httpx.AsyncClient(timeout=15.0) as http:
294
+ lb = await http.post(
295
+ url,
296
+ headers=_load_balancer_headers(request),
297
+ content="{}",
298
+ )
299
  except httpx.RequestError as exc:
300
  logger.warning("Load balancer unreachable: %r", exc)
301
  raise HTTPException(status_code=502, detail="Speech service unreachable.")
 
331
  return await _finalize_grant(data, keys, tier, tracked, set_cookie)
332
 
333
 
334
+ def _load_balancer_headers(request: Request) -> dict[str, str]:
335
+ """Headers for the server-to-server session allocation request.
336
+
337
+ Reachy Mini uses this dedicated header for an optional HF user token. The
338
+ load balancer fingerprints it immediately and resolves the account through
339
+ whoami asynchronously, so allocation remains fast. Anonymous visitors send
340
+ no credential header.
341
+ """
342
+ headers = {
343
+ "Content-Type": "application/json",
344
+ "User-Agent": LB_USER_AGENT,
345
+ }
346
+ token = auth.current_access_token(request)
347
+ if token:
348
+ headers["X-Reachy-Mini-Authorization"] = f"Bearer {token}"
349
+ return headers
350
+
351
+
352
  @app.get("/api/queue/{queue_id}")
353
  async def queue_status(queue_id: str, request: Request):
354
  """Poll a waiting ticket: relay the position, or — when the head of the line
 
357
  if not LOAD_BALANCER_URL:
358
  raise HTTPException(status_code=404, detail="Not found.")
359
 
360
+ login_error = _login_gate(request)
361
+ if login_error is not None:
362
+ return login_error
363
+
364
  tier, keys, set_cookie = auth.resolve_identity(request)
365
  tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
366
 
style.css CHANGED
@@ -1752,6 +1752,52 @@ body.cam-on .footer {
1752
  .bubble.user .bubble-role { color: var(--voice-user); }
1753
  .bubble.assistant .bubble-role { color: var(--voice-assistant); }
1754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1755
  /* ─── Conversation history panel ─────────────────────────────────────── */
1756
 
1757
  .chat-panel {
@@ -1884,6 +1930,29 @@ body.cam-on .footer {
1884
  * distinguishing, so the panel reads as one quiet column. */
1885
  .hist-msg.user .hist-body.partial { opacity: 0.65; }
1886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1887
  /* ─── Tool call history item ─────────────────────────────────────────── */
1888
 
1889
  .hist-tool-header {
@@ -2549,6 +2618,9 @@ body.cam-on .footer {
2549
  }
2550
  }
2551
  @media (prefers-reduced-motion: reduce) {
 
 
 
2552
  .cam-pip {
2553
  transition: opacity 0.22s ease;
2554
  transform: none;
 
1752
  .bubble.user .bubble-role { color: var(--voice-user); }
1753
  .bubble.assistant .bubble-role { color: var(--voice-assistant); }
1754
 
1755
+ .bubble.user.voice .bubble-body {
1756
+ display: flex;
1757
+ align-items: center;
1758
+ gap: 9px;
1759
+ min-width: 126px;
1760
+ color: var(--text-dim);
1761
+ }
1762
+ .voice-turn-wave {
1763
+ display: inline-flex;
1764
+ align-items: center;
1765
+ gap: 2px;
1766
+ height: 16px;
1767
+ color: var(--voice-user);
1768
+ }
1769
+ .voice-turn-wave i {
1770
+ display: block;
1771
+ width: 2px;
1772
+ height: 12px;
1773
+ border-radius: 999px;
1774
+ background: currentColor;
1775
+ transform-origin: center;
1776
+ animation: user-turn-wave 0.85s ease-in-out infinite;
1777
+ }
1778
+ .voice-turn-wave i:nth-child(2),
1779
+ .voice-turn-wave i:nth-child(4) {
1780
+ animation-delay: -0.22s;
1781
+ }
1782
+ .voice-turn-wave i:nth-child(3) {
1783
+ animation-delay: -0.44s;
1784
+ }
1785
+ .voice-turn-wave i:nth-child(5) {
1786
+ animation-delay: -0.11s;
1787
+ }
1788
+ .bubble.user.sending .voice-turn-wave i {
1789
+ animation-duration: 1.15s;
1790
+ }
1791
+ .voice-turn-state {
1792
+ font-family: var(--font-mono);
1793
+ font-size: 11px;
1794
+ letter-spacing: 0.02em;
1795
+ }
1796
+ @keyframes user-turn-wave {
1797
+ 0%, 100% { transform: scaleY(0.28); opacity: 0.5; }
1798
+ 50% { transform: scaleY(1); opacity: 1; }
1799
+ }
1800
+
1801
  /* ─── Conversation history panel ─────────────────────────────────────── */
1802
 
1803
  .chat-panel {
 
1930
  * distinguishing, so the panel reads as one quiet column. */
1931
  .hist-msg.user .hist-body.partial { opacity: 0.65; }
1932
 
1933
+ .hist-audio {
1934
+ display: flex;
1935
+ flex-direction: column;
1936
+ gap: 5px;
1937
+ padding: 8px 10px;
1938
+ border: 1px solid color-mix(in srgb, var(--voice-user) 22%, var(--border));
1939
+ border-radius: var(--radius-md);
1940
+ background: color-mix(in srgb, var(--voice-user) 6%, var(--bg-elev-2));
1941
+ }
1942
+ .hist-audio-label {
1943
+ font-family: var(--font-mono);
1944
+ font-size: 9px;
1945
+ letter-spacing: 0.06em;
1946
+ text-transform: uppercase;
1947
+ color: color-mix(in srgb, var(--voice-user) 68%, var(--text-faint));
1948
+ }
1949
+ .hist-audio audio {
1950
+ display: block;
1951
+ width: min(260px, 100%);
1952
+ height: 34px;
1953
+ color-scheme: light dark;
1954
+ }
1955
+
1956
  /* ─── Tool call history item ─────────────────────────────────────────── */
1957
 
1958
  .hist-tool-header {
 
2618
  }
2619
  }
2620
  @media (prefers-reduced-motion: reduce) {
2621
+ .voice-turn-wave i {
2622
+ animation: none;
2623
+ }
2624
  .cam-pip {
2625
  transition: opacity 0.22s ease;
2626
  transform: none;
ui/account.js CHANGED
@@ -36,7 +36,7 @@ export class Account {
36
  /** @type {HTMLAnchorElement} */
37
  this._modalCta = /** @type {any} */ ($("#limit-cta"));
38
 
39
- /** @type {{enabled:boolean, auth?:boolean, loggedIn?:boolean, username?:string, avatar?:string, tier?:string, remainingSec?:number|null, limitSec?:number|null, loginUrl?:string|null, logoutUrl?:string|null}} */
40
  this._me = { enabled: false };
41
  this._popoverOpen = false;
42
 
@@ -56,6 +56,14 @@ export class Account {
56
  return this._me.tier || "anon";
57
  }
58
 
 
 
 
 
 
 
 
 
59
  /** Fetch `/api/me` and (re)render the chip. Safe to call repeatedly (load,
60
  * after the OAuth redirect, after a conversation ends). */
61
  async refresh() {
@@ -80,7 +88,8 @@ export class Account {
80
  if (!me.loggedIn) {
81
  // Signed-out: a sign-in pill (only when OAuth is actually available).
82
  if (me.auth && me.loginUrl) {
83
- this._root.innerHTML = `<a class="signin-pill" href="${escHtml(me.loginUrl)}" title="Sign in for more time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg><span>Sign in</span></a>`;
 
84
  } else {
85
  this._root.innerHTML = "";
86
  this._root.hidden = true;
@@ -176,6 +185,23 @@ export class Account {
176
  if (!this._modal.open) this._modal.showModal();
177
  }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  /** Show a warm "we're at capacity" message when even the waiting line is full.
180
  * Reuses the limit modal shell; no call-to-action, just reassurance. */
181
  showBusy() {
 
36
  /** @type {HTMLAnchorElement} */
37
  this._modalCta = /** @type {any} */ ($("#limit-cta"));
38
 
39
+ /** @type {{enabled:boolean, auth?:boolean, loginRequired?:boolean, loggedIn?:boolean, username?:string, avatar?:string, tier?:string, remainingSec?:number|null, limitSec?:number|null, loginUrl?:string|null, logoutUrl?:string|null}} */
40
  this._me = { enabled: false };
41
  this._popoverOpen = false;
42
 
 
56
  return this._me.tier || "anon";
57
  }
58
 
59
+ get loggedIn() {
60
+ return !!this._me.loggedIn;
61
+ }
62
+
63
+ get loginUrl() {
64
+ return this._me.loginUrl || "";
65
+ }
66
+
67
  /** Fetch `/api/me` and (re)render the chip. Safe to call repeatedly (load,
68
  * after the OAuth redirect, after a conversation ends). */
69
  async refresh() {
 
88
  if (!me.loggedIn) {
89
  // Signed-out: a sign-in pill (only when OAuth is actually available).
90
  if (me.auth && me.loginUrl) {
91
+ const title = me.loginRequired ? "Sign in to use the demo" : "Sign in for more time";
92
+ this._root.innerHTML = `<a class="signin-pill" href="${escHtml(me.loginUrl)}" title="${escHtml(title)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg><span>Sign in</span></a>`;
93
  } else {
94
  this._root.innerHTML = "";
95
  this._root.hidden = true;
 
185
  if (!this._modal.open) this._modal.showModal();
186
  }
187
 
188
+ /** Prompt an anonymous visitor to sign in before starting a conversation.
189
+ * @param {string} [loginUrl] */
190
+ showLoginRequired(loginUrl = this.loginUrl) {
191
+ this._modalTitle.textContent = "Sign in to start";
192
+ this._modalMsg.textContent =
193
+ "This demo is available to signed-in Hugging Face users.";
194
+ this._modalNote.textContent = "A Hugging Face account is free.";
195
+ if (loginUrl) {
196
+ this._modalCta.innerHTML = `${HF_MARK}<span>Sign in with Hugging Face</span>`;
197
+ this._modalCta.href = loginUrl;
198
+ this._modalCta.hidden = false;
199
+ } else {
200
+ this._modalCta.hidden = true;
201
+ }
202
+ if (!this._modal.open) this._modal.showModal();
203
+ }
204
+
205
  /** Show a warm "we're at capacity" message when even the waiting line is full.
206
  * Reuses the limit modal shell; no call-to-action, just reassurance. */
207
  showBusy() {
ui/chat.js CHANGED
@@ -2,8 +2,8 @@
2
  /**
3
  * ChatView — owns the whole conversation surface: the slide-in history panel,
4
  * the ephemeral on-orb bubbles, and all the transcript/tool/streaming
5
- * bookkeeping. main.js wires the realtime client's events straight to the
6
- * `on*` methods here and otherwise doesn't touch chat state.
7
  *
8
  * Two parallel surfaces share one shape (see `_buildMessageEl`):
9
  * - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer
@@ -24,7 +24,10 @@ const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="n
24
  const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
25
 
26
  export class ChatView {
27
- constructor() {
 
 
 
28
  /** @type {HTMLButtonElement} */
29
  this._chatBtn = $("#chat-btn");
30
  /** @type {HTMLSpanElement} */
@@ -46,6 +49,13 @@ export class ChatView {
46
  // ── User transcript state (keyed by item_id) ───────────────────────────
47
  /** @type {Map<string, HTMLElement>} */
48
  this._userHistByItem = new Map();
 
 
 
 
 
 
 
49
  /** @type {HTMLElement | null} */
50
  this._activeUserBubble = null;
51
  this._activeUserItemId = "";
@@ -121,7 +131,7 @@ export class ChatView {
121
  const el = document.createElement("div");
122
  el.className = `${container} ${role}`;
123
  const label = role === "user" ? "You" : "Assistant";
124
- el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
125
  return el;
126
  }
127
 
@@ -148,10 +158,50 @@ export class ChatView {
148
  return el;
149
  }
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  /** @param {HTMLElement} el @param {string} text */
152
  _updateBubbleText(el, text) {
153
- const t = el.querySelector(".bubble-body");
154
- if (t) t.textContent = text;
 
 
 
 
 
 
155
  }
156
 
157
  /** @param {HTMLElement} el */
@@ -195,6 +245,25 @@ export class ChatView {
195
  }
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  /**
199
  * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
200
  * Calling it again resets the countdown — so a bubble that keeps updating
@@ -204,7 +273,20 @@ export class ChatView {
204
  */
205
  _bumpDismiss(el, delay = 4000) {
206
  this._bubbleExpiry.set(el, Date.now() + delay);
207
- if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay);
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  }
209
 
210
  // ── History ───────────────────────────────────────────────────────────────
@@ -216,6 +298,11 @@ export class ChatView {
216
 
217
  /** Reset the panel to the empty state and clear the unread badge. */
218
  clear() {
 
 
 
 
 
219
  this.renderEmptyState();
220
  this._chatBadge.classList.remove("visible");
221
  }
@@ -236,10 +323,28 @@ export class ChatView {
236
  const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
237
  if (!body) return;
238
  body.textContent = text;
 
239
  body.classList.toggle("partial", partial);
240
  this._scrollToBottom();
241
  }
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  /**
244
  * Append a tool-call row to the conversation. We only add it once the tool
245
  * has run, so the expandable toggle carries BOTH the call input and its result.
@@ -320,6 +425,46 @@ export class ChatView {
320
 
321
  // ── Client event handlers ─────────────────────────────────────────────────
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  /**
324
  * A streamed transcript delta (user or assistant).
325
  * @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d
@@ -335,19 +480,18 @@ export class ChatView {
335
  const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
336
  const text = d.text;
337
 
338
- let hist = this._userHistByItem.get(id);
339
- if (!hist) {
340
- hist = this._appendHistMsg("user", text, d.partial);
341
- this._userHistByItem.set(id, hist);
342
- } else {
343
- this._updateHistMsg(hist, text, d.partial);
344
- }
345
 
346
  // One ephemeral bubble per active item. Purely timer-based: the timer is
347
  // refreshed on every delta, so it stays while the user keeps talking and
348
  // fades a few seconds after they stop — no dependency on a response ever
349
  // arriving, so it can never get stuck.
350
- if (this._activeUserItemId !== id || !this._activeUserBubble) {
 
 
 
 
351
  this._activeUserBubble = this._spawnBubble("user", text);
352
  this._activeUserItemId = id;
353
  } else {
@@ -356,6 +500,7 @@ export class ChatView {
356
  this._bumpDismiss(this._activeUserBubble, 6000);
357
  this._markUnread();
358
  } else if (d.role === "assistant") {
 
359
  // Assistant transcript arrives once, as the full text, keyed by
360
  // response_id so a cancelled speculative response can be removed later. A
361
  // missing id gets a unique key so two id-less replies never collide.
@@ -374,6 +519,71 @@ export class ChatView {
374
  }
375
  }
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  /**
378
  * A response closed (completed or cancelled).
379
  * @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail
@@ -381,6 +591,7 @@ export class ChatView {
381
  onResponseFinished(detail) {
382
  const { responseId, status, audible, transcript } = detail;
383
  if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
 
384
  // Without an id we can't target a specific response; the bubble will
385
  // auto-dismiss on its own timer regardless.
386
  if (!responseId) return;
 
2
  /**
3
  * ChatView — owns the whole conversation surface: the slide-in history panel,
4
  * the ephemeral on-orb bubbles, and all the transcript/tool/streaming
5
+ * and user-audio bookkeeping. main.js wires the realtime client's events
6
+ * straight to the `on*` methods here and otherwise doesn't touch chat state.
7
  *
8
  * Two parallel surfaces share one shape (see `_buildMessageEl`):
9
  * - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer
 
24
  const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
25
 
26
  export class ChatView {
27
+ /**
28
+ * @param {{ onUserAudioPlaybackChange?: (playing: boolean) => void }} [options]
29
+ */
30
+ constructor(options = {}) {
31
  /** @type {HTMLButtonElement} */
32
  this._chatBtn = $("#chat-btn");
33
  /** @type {HTMLSpanElement} */
 
49
  // ── User transcript state (keyed by item_id) ───────────────────────────
50
  /** @type {Map<string, HTMLElement>} */
51
  this._userHistByItem = new Map();
52
+ /** @type {Map<string, { audio: HTMLAudioElement, url: string }>} */
53
+ this._userAudioByItem = new Map();
54
+ /** @type {Set<string>} */
55
+ this._audioUrls = new Set();
56
+ /** @type {HTMLAudioElement | null} */
57
+ this._activeUserAudio = null;
58
+ this._onUserAudioPlaybackChange = options.onUserAudioPlaybackChange ?? (() => {});
59
  /** @type {HTMLElement | null} */
60
  this._activeUserBubble = null;
61
  this._activeUserItemId = "";
 
131
  const el = document.createElement("div");
132
  el.className = `${container} ${role}`;
133
  const label = role === "user" ? "You" : "Assistant";
134
+ el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}"${text ? "" : " hidden"}>${escHtml(text)}</div>`;
135
  return el;
136
  }
137
 
 
158
  return el;
159
  }
160
 
161
+ /**
162
+ * Show an immediate placeholder for a user voice turn. It occupies the same
163
+ * bubble as a transcript would, so a late STT event can replace the animation
164
+ * in place instead of adding a second user message.
165
+ * @param {"listening"|"sending"} state
166
+ * @returns {HTMLElement}
167
+ */
168
+ _spawnVoiceBubble(state) {
169
+ const el = this._spawnBubble("user", "");
170
+ el.classList.add("voice");
171
+ el.setAttribute("role", "status");
172
+ el.setAttribute("aria-live", "polite");
173
+ const body = /** @type {HTMLElement | null} */ (el.querySelector(".bubble-body"));
174
+ if (body) {
175
+ body.hidden = false;
176
+ body.innerHTML = `
177
+ <span class="voice-turn-wave" aria-hidden="true">
178
+ <i></i><i></i><i></i><i></i><i></i>
179
+ </span>
180
+ <span class="voice-turn-state"></span>
181
+ `;
182
+ }
183
+ this._setVoiceBubbleState(el, state);
184
+ return el;
185
+ }
186
+
187
+ /** @param {HTMLElement} el @param {"listening"|"sending"} state */
188
+ _setVoiceBubbleState(el, state) {
189
+ el.classList.toggle("listening", state === "listening");
190
+ el.classList.toggle("sending", state === "sending");
191
+ const label = el.querySelector(".voice-turn-state");
192
+ if (label) label.textContent = state === "listening" ? "Listening…" : "Sending voice…";
193
+ }
194
+
195
  /** @param {HTMLElement} el @param {string} text */
196
  _updateBubbleText(el, text) {
197
+ el.classList.remove("voice", "listening", "sending");
198
+ el.removeAttribute("role");
199
+ el.removeAttribute("aria-live");
200
+ const t = /** @type {HTMLElement | null} */ (el.querySelector(".bubble-body"));
201
+ if (t) {
202
+ t.textContent = text;
203
+ t.hidden = !text;
204
+ }
205
  }
206
 
207
  /** @param {HTMLElement} el */
 
245
  }
246
  }
247
 
248
+ /**
249
+ * Point the shared reaper at the current oldest bubble. This must run when an
250
+ * expiry moves earlier as well as later: a listening bubble starts with a
251
+ * long fail-safe, then gets a much shorter deadline once speech stops.
252
+ */
253
+ _scheduleBubbleReaper() {
254
+ if (this._reaperHandle) clearTimeout(this._reaperHandle);
255
+ this._reaperHandle = 0;
256
+ const oldest = /** @type {HTMLElement | null} */ (
257
+ this._bubbleStack.querySelector(".bubble:not(.out)")
258
+ );
259
+ if (!oldest) return;
260
+ const expiry = this._bubbleExpiry.get(oldest) ?? Date.now();
261
+ this._reaperHandle = setTimeout(
262
+ () => this._reapBubbles(),
263
+ Math.max(50, expiry - Date.now()),
264
+ );
265
+ }
266
+
267
  /**
268
  * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
269
  * Calling it again resets the countdown — so a bubble that keeps updating
 
273
  */
274
  _bumpDismiss(el, delay = 4000) {
275
  this._bubbleExpiry.set(el, Date.now() + delay);
276
+ this._scheduleBubbleReaper();
277
+ }
278
+
279
+ /**
280
+ * Remove a pending voice placeholder once the assistant has visibly started
281
+ * answering. A transcript can arrive before audible output, while direct
282
+ * audio models may only trigger the ai-speaking status, so both call here.
283
+ */
284
+ onAssistantActivity() {
285
+ const bubble = this._activeUserBubble;
286
+ if (!bubble?.isConnected || !bubble.classList.contains("voice")) return;
287
+ this._dismissBubble(bubble);
288
+ this._activeUserBubble = null;
289
+ this._scheduleBubbleReaper();
290
  }
291
 
292
  // ── History ───────────────────────────────────────────────────────────────
 
298
 
299
  /** Reset the panel to the empty state and clear the unread badge. */
300
  clear() {
301
+ this._stopUserAudioPlayback();
302
+ for (const url of this._audioUrls) URL.revokeObjectURL(url);
303
+ this._audioUrls.clear();
304
+ this._userAudioByItem.clear();
305
+ this._userHistByItem.clear();
306
  this.renderEmptyState();
307
  this._chatBadge.classList.remove("visible");
308
  }
 
323
  const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
324
  if (!body) return;
325
  body.textContent = text;
326
+ body.hidden = !text;
327
  body.classList.toggle("partial", partial);
328
  this._scrollToBottom();
329
  }
330
 
331
+ /** @param {string} itemId */
332
+ _ensureUserHist(itemId) {
333
+ let hist = this._userHistByItem.get(itemId);
334
+ if (!hist) {
335
+ hist = this._appendHistMsg("user", "", false);
336
+ this._userHistByItem.set(itemId, hist);
337
+ }
338
+ return hist;
339
+ }
340
+
341
+ _stopUserAudioPlayback() {
342
+ const active = this._activeUserAudio;
343
+ this._activeUserAudio = null;
344
+ if (active && !active.paused) active.pause();
345
+ this._onUserAudioPlaybackChange(false);
346
+ }
347
+
348
  /**
349
  * Append a tool-call row to the conversation. We only add it once the tool
350
  * has run, so the expandable toggle carries BOTH the call input and its result.
 
425
 
426
  // ── Client event handlers ─────────────────────────────────────────────────
427
 
428
+ /**
429
+ * Show a voice bubble as soon as backend VAD recognizes speech.
430
+ * @param {{ itemId?: string }} [detail]
431
+ */
432
+ onUserTurnStarted(detail = {}) {
433
+ const id = detail.itemId || `_u${++this._anonSeq}`;
434
+ const reusable = this._activeUserItemId === id
435
+ && this._activeUserBubble?.isConnected
436
+ && !this._activeUserBubble.classList.contains("out");
437
+ if (!reusable) {
438
+ if (this._activeUserBubble?.classList.contains("voice")) {
439
+ this._dismissBubble(this._activeUserBubble);
440
+ }
441
+ this._activeUserBubble = this._spawnVoiceBubble("listening");
442
+ this._activeUserItemId = id;
443
+ } else if (this._activeUserBubble.classList.contains("voice")) {
444
+ this._setVoiceBubbleState(this._activeUserBubble, "listening");
445
+ }
446
+ this._bumpDismiss(this._activeUserBubble, 30000);
447
+ }
448
+
449
+ /**
450
+ * Transition the active voice bubble while the closed turn is in flight.
451
+ * If speech_started was missed, create the sending state directly.
452
+ * @param {{ itemId?: string }} [detail]
453
+ */
454
+ onUserTurnStopped(detail = {}) {
455
+ const id = detail.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
456
+ const reusable = this._activeUserItemId === id
457
+ && this._activeUserBubble?.isConnected
458
+ && !this._activeUserBubble.classList.contains("out");
459
+ if (!reusable) {
460
+ this._activeUserBubble = this._spawnVoiceBubble("sending");
461
+ this._activeUserItemId = id;
462
+ } else if (this._activeUserBubble.classList.contains("voice")) {
463
+ this._setVoiceBubbleState(this._activeUserBubble, "sending");
464
+ }
465
+ this._bumpDismiss(this._activeUserBubble, 6000);
466
+ }
467
+
468
  /**
469
  * A streamed transcript delta (user or assistant).
470
  * @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d
 
480
  const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
481
  const text = d.text;
482
 
483
+ const hist = this._ensureUserHist(id);
484
+ this._updateHistMsg(hist, text, d.partial);
 
 
 
 
 
485
 
486
  // One ephemeral bubble per active item. Purely timer-based: the timer is
487
  // refreshed on every delta, so it stays while the user keeps talking and
488
  // fades a few seconds after they stop — no dependency on a response ever
489
  // arriving, so it can never get stuck.
490
+ if (
491
+ this._activeUserItemId !== id
492
+ || !this._activeUserBubble?.isConnected
493
+ || this._activeUserBubble.classList.contains("out")
494
+ ) {
495
  this._activeUserBubble = this._spawnBubble("user", text);
496
  this._activeUserItemId = id;
497
  } else {
 
500
  this._bumpDismiss(this._activeUserBubble, 6000);
501
  this._markUnread();
502
  } else if (d.role === "assistant") {
503
+ this.onAssistantActivity();
504
  // Assistant transcript arrives once, as the full text, keyed by
505
  // response_id so a cancelled speculative response can be removed later. A
506
  // missing id gets a unique key so two id-less replies never collide.
 
519
  }
520
  }
521
 
522
+ /**
523
+ * Attach the browser-local recording to its user turn. This also creates an
524
+ * audio-only row when STT is disabled and no transcript events arrive.
525
+ * Reopened VAD segments reuse item_id; the client sends a replacement WAV
526
+ * containing the accumulated utterance, so the row keeps one player.
527
+ * @param {{ itemId?: string, audio: Blob, durationMs?: number, truncated?: boolean }} detail
528
+ */
529
+ onUserAudio(detail) {
530
+ const id = detail.itemId || `_u${++this._anonSeq}`;
531
+ const hist = this._ensureUserHist(id);
532
+ let container = /** @type {HTMLElement | null} */ (hist.querySelector(".hist-audio"));
533
+ const isNewPlayer = !container;
534
+ if (!container) {
535
+ container = document.createElement("div");
536
+ container.className = "hist-audio";
537
+ container.innerHTML = `
538
+ <div class="hist-audio-label">Audio sent to the model</div>
539
+ <audio controls preload="metadata" aria-label="Replay your audio"></audio>
540
+ `;
541
+ hist.appendChild(container);
542
+ }
543
+
544
+ const audio = /** @type {HTMLAudioElement} */ (container.querySelector("audio"));
545
+ const previous = this._userAudioByItem.get(id);
546
+ if (previous) {
547
+ if (this._activeUserAudio === previous.audio) this._stopUserAudioPlayback();
548
+ URL.revokeObjectURL(previous.url);
549
+ this._audioUrls.delete(previous.url);
550
+ }
551
+
552
+ const url = URL.createObjectURL(detail.audio);
553
+ this._audioUrls.add(url);
554
+ this._userAudioByItem.set(id, { audio, url });
555
+ audio.src = url;
556
+ audio.title = detail.truncated
557
+ ? "Replay your audio (the beginning was no longer buffered)"
558
+ : "Replay the audio sent to the model";
559
+ const label = container.querySelector(".hist-audio-label");
560
+ if (label) {
561
+ label.textContent = detail.truncated
562
+ ? "Audio sent to the model · beginning unavailable"
563
+ : "Audio sent to the model";
564
+ }
565
+
566
+ if (isNewPlayer) {
567
+ audio.addEventListener("play", () => {
568
+ if (this._activeUserAudio && this._activeUserAudio !== audio) {
569
+ this._activeUserAudio.pause();
570
+ }
571
+ this._activeUserAudio = audio;
572
+ this._onUserAudioPlaybackChange(true);
573
+ });
574
+ const stopped = () => {
575
+ if (this._activeUserAudio !== audio) return;
576
+ this._activeUserAudio = null;
577
+ this._onUserAudioPlaybackChange(false);
578
+ };
579
+ audio.addEventListener("pause", stopped);
580
+ audio.addEventListener("ended", stopped);
581
+ audio.addEventListener("error", stopped);
582
+ }
583
+ this._scrollToBottom();
584
+ this._markUnread();
585
+ }
586
+
587
  /**
588
  * A response closed (completed or cancelled).
589
  * @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail
 
591
  onResponseFinished(detail) {
592
  const { responseId, status, audible, transcript } = detail;
593
  if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
594
+ this.onAssistantActivity();
595
  // Without an id we can't target a specific response; the bubble will
596
  // auto-dismiss on its own timer regardless.
597
  if (!responseId) return;
ws/s2s-ws-client.js CHANGED
@@ -51,6 +51,8 @@
51
  * session POST and dials it directly — no load balancer in between.
52
  * @property {string} voice
53
  * @property {string} instructions
 
 
54
  * @property {MediaStream} [micStream] Live mic stream. Provide this OR `acquireMic`.
55
  * @property {() => Promise<MediaStream>} [acquireMic] Lazily obtain the mic stream,
56
  * called only once a session is actually granted (after any queue wait). Lets the
@@ -65,6 +67,8 @@
65
  * executes and replies via `sendToolOutput` + `requestResponse`.
66
  * @property {NoiseGate} [noiseGate] Client-side noise gate applied to the mic
67
  * before it's sent. Tunable live via `setNoiseGate`.
 
 
68
  *
69
  * @typedef {Object} NoiseGate
70
  * @property {boolean} enabled
@@ -89,6 +93,7 @@ import {
89
  trimTrailingSlash,
90
  } from "./codec.js";
91
  import { OrbVisualiser, VIS_FFT_SIZE } from "./orb-visualizer.js";
 
92
 
93
  /** Build an Error carrying a `code` (and optional extra fields) so callers can
94
  * branch on the failure kind: "limit" | "queue-full" | "queue-expired" | "aborted".
@@ -198,6 +203,11 @@ export class S2sWsRealtimeClient extends EventTarget {
198
  /** @type {Promise<void> | null} */
199
  this._readyPromise = null;
200
  this._sessionConfigured = false;
 
 
 
 
 
201
  this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })();
202
  }
203
 
@@ -346,6 +356,12 @@ export class S2sWsRealtimeClient extends EventTarget {
346
  headers: { "Content-Type": "application/json" },
347
  body: "{}",
348
  });
 
 
 
 
 
 
349
  if (response.status === 402) {
350
  // The session proxy refused: today's per-tier time budget is spent. Surface
351
  // it as a typed error so the UI shows the limit modal, not a crash.
@@ -404,6 +420,12 @@ export class S2sWsRealtimeClient extends EventTarget {
404
  const body = await response.json().catch(() => ({}));
405
  throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
406
  }
 
 
 
 
 
 
407
  if (response.status === 404) {
408
  throw _codedError("Queue timed out", "queue-expired");
409
  }
@@ -526,10 +548,30 @@ export class S2sWsRealtimeClient extends EventTarget {
526
  this._outAnalyser = outAnalyser;
527
  this._playbackNode = playbackNode;
528
 
 
 
529
  this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
530
  this._visualiser.start();
531
  }
532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  /** @param {string} connectUrl */
534
  _openWebSocket(connectUrl) {
535
  return new Promise((resolve, reject) => {
@@ -581,6 +623,9 @@ export class S2sWsRealtimeClient extends EventTarget {
581
  if (this._muted) return;
582
  const b64 = base64FromArrayBuffer(pcm16Buffer);
583
  this._send({ type: "input_audio_buffer.append", audio: b64 });
 
 
 
584
  }
585
 
586
  /**
@@ -625,11 +670,16 @@ export class S2sWsRealtimeClient extends EventTarget {
625
  // out). We only push the user-tunable bits: voice + instructions.
626
  this._sendSessionUpdate();
627
  this._sessionConfigured = true;
 
 
 
 
628
  if (this._status === "connecting") this._setStatus("connected");
629
  break;
630
 
631
  case "session.updated":
632
- // Acknowledged by server, nothing to do.
 
633
  break;
634
 
635
  case "input_audio_buffer.speech_started":
@@ -640,10 +690,32 @@ export class S2sWsRealtimeClient extends EventTarget {
640
  // otherwise keep playing over the user's barge-in.
641
  this._playbackNode?.port.postMessage({ kind: "clear" });
642
  this._aiSpeaking = false;
 
 
 
 
 
 
 
 
 
643
  this._setStatus("user-speaking");
644
  break;
645
 
646
  case "input_audio_buffer.speech_stopped":
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  if (this._status === "user-speaking") this._setStatus("processing");
648
  break;
649
 
@@ -965,6 +1037,26 @@ export class S2sWsRealtimeClient extends EventTarget {
965
  });
966
  }
967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
  /**
969
  * Ask the model to generate a response now (after feeding tool results).
970
  * Serialized: if a response is already in flight we queue this request and
@@ -1035,6 +1127,7 @@ export class S2sWsRealtimeClient extends EventTarget {
1035
  // Abort a queue wait in progress: flag it and wake the poll sleep so
1036
  // `_pollQueue` throws "aborted" and connect() unwinds cleanly.
1037
  this._closed = true;
 
1038
  if (this._queueWake) {
1039
  clearTimeout(this._queueTimer);
1040
  const wake = this._queueWake;
 
51
  * session POST and dials it directly — no load balancer in between.
52
  * @property {string} voice
53
  * @property {string} instructions
54
+ * @property {string} [startupGreeting] Hidden user prompt that asks the model
55
+ * to greet once after the initial session configuration is sent.
56
  * @property {MediaStream} [micStream] Live mic stream. Provide this OR `acquireMic`.
57
  * @property {() => Promise<MediaStream>} [acquireMic] Lazily obtain the mic stream,
58
  * called only once a session is actually granted (after any queue wait). Lets the
 
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
 
93
  trimTrailingSlash,
94
  } from "./codec.js";
95
  import { OrbVisualiser, VIS_FFT_SIZE } from "./orb-visualizer.js";
96
+ import { SentAudioRecorder } from "./user-audio-recorder.js";
97
 
98
  /** Build an Error carrying a `code` (and optional extra fields) so callers can
99
  * branch on the failure kind: "limit" | "queue-full" | "queue-expired" | "aborted".
 
203
  /** @type {Promise<void> | null} */
204
  this._readyPromise = null;
205
  this._sessionConfigured = false;
206
+ this._startupGreeting = options.startupGreeting?.trim() ?? "";
207
+ this._startupGreetingSent = false;
208
+ // Bounded, browser-local copy of the exact PCM frames sent over this
209
+ // socket. Backend VAD timestamps turn it into replayable user utterances.
210
+ this._userAudioRecorder = new SentAudioRecorder();
211
  this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })();
212
  }
213
 
 
356
  headers: { "Content-Type": "application/json" },
357
  body: "{}",
358
  });
359
+ if (response.status === 401) {
360
+ const body = await response.json().catch(() => ({}));
361
+ throw _codedError("Hugging Face sign-in required", "login-required", {
362
+ loginUrl: body?.loginUrl,
363
+ });
364
+ }
365
  if (response.status === 402) {
366
  // The session proxy refused: today's per-tier time budget is spent. Surface
367
  // it as a typed error so the UI shows the limit modal, not a crash.
 
420
  const body = await response.json().catch(() => ({}));
421
  throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
422
  }
423
+ if (response.status === 401) {
424
+ const body = await response.json().catch(() => ({}));
425
+ throw _codedError("Hugging Face sign-in required", "login-required", {
426
+ loginUrl: body?.loginUrl,
427
+ });
428
+ }
429
  if (response.status === 404) {
430
  throw _codedError("Queue timed out", "queue-expired");
431
  }
 
548
  this._outAnalyser = outAnalyser;
549
  this._playbackNode = playbackNode;
550
 
551
+ await this.setAudioOutputDevice(this.options.audioOutputId || "");
552
+
553
  this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking);
554
  this._visualiser.start();
555
  }
556
 
557
+ /**
558
+ * Route Web Audio playback to a specific output device (Chrome/Edge).
559
+ * Empty string restores the system default.
560
+ * @param {string} [deviceId]
561
+ * @returns {Promise<boolean>}
562
+ */
563
+ async setAudioOutputDevice(deviceId = "") {
564
+ const ctx = this._ctx;
565
+ if (!ctx || typeof /** @type {any} */ (ctx).setSinkId !== "function") return false;
566
+ try {
567
+ await /** @type {any} */ (ctx).setSinkId(deviceId || "");
568
+ return true;
569
+ } catch (err) {
570
+ console.warn("[ws] setSinkId failed:", err);
571
+ return false;
572
+ }
573
+ }
574
+
575
  /** @param {string} connectUrl */
576
  _openWebSocket(connectUrl) {
577
  return new Promise((resolve, reject) => {
 
623
  if (this._muted) return;
624
  const b64 = base64FromArrayBuffer(pcm16Buffer);
625
  this._send({ type: "input_audio_buffer.append", audio: b64 });
626
+ // Record only after the append is accepted for sending. This intentionally
627
+ // excludes pre-configuration and muted audio, just like the backend input.
628
+ this._userAudioRecorder.append(pcm16Buffer);
629
  }
630
 
631
  /**
 
670
  // out). We only push the user-tunable bits: voice + instructions.
671
  this._sendSessionUpdate();
672
  this._sessionConfigured = true;
673
+ // The s2s server does not echo session.updated. WebSocket messages are
674
+ // ordered, so this hidden item and response.create are handled only
675
+ // after the session.update sent immediately above.
676
+ this._sendStartupGreeting();
677
  if (this._status === "connecting") this._setStatus("connected");
678
  break;
679
 
680
  case "session.updated":
681
+ // Some Realtime servers acknowledge session.update; the greeting was
682
+ // already queued from session.created and is guarded against repeats.
683
  break;
684
 
685
  case "input_audio_buffer.speech_started":
 
690
  // otherwise keep playing over the user's barge-in.
691
  this._playbackNode?.port.postMessage({ kind: "clear" });
692
  this._aiSpeaking = false;
693
+ this._userAudioRecorder.speechStarted({
694
+ itemId: typeof event.item_id === "string" ? event.item_id : "",
695
+ audioStartMs: Number(event.audio_start_ms),
696
+ });
697
+ this.dispatchEvent(new CustomEvent("user-turn-started", {
698
+ detail: {
699
+ itemId: typeof event.item_id === "string" ? event.item_id : "",
700
+ },
701
+ }));
702
  this._setStatus("user-speaking");
703
  break;
704
 
705
  case "input_audio_buffer.speech_stopped":
706
+ {
707
+ const itemId = typeof event.item_id === "string" ? event.item_id : "";
708
+ const recording = this._userAudioRecorder.speechStopped({
709
+ itemId,
710
+ audioEndMs: Number(event.audio_end_ms),
711
+ });
712
+ if (recording) {
713
+ this.dispatchEvent(new CustomEvent("user-audio", { detail: recording }));
714
+ }
715
+ this.dispatchEvent(new CustomEvent("user-turn-stopped", {
716
+ detail: { itemId },
717
+ }));
718
+ }
719
  if (this._status === "user-speaking") this._setStatus("processing");
720
  break;
721
 
 
1037
  });
1038
  }
1039
 
1040
+ /**
1041
+ * Ask the model to open the conversation exactly once. The synthetic user
1042
+ * prompt stays in conversation history, so the greeting warms the actual
1043
+ * prompt prefix reused by the first spoken turn.
1044
+ */
1045
+ _sendStartupGreeting() {
1046
+ if (!this._startupGreeting || this._startupGreetingSent) return;
1047
+ this._startupGreetingSent = true;
1048
+ this._send({
1049
+ type: "conversation.item.create",
1050
+ item: {
1051
+ type: "message",
1052
+ role: "user",
1053
+ content: [{ type: "input_text", text: this._startupGreeting }],
1054
+ },
1055
+ });
1056
+ this.requestResponse();
1057
+ if (this._debug) console.debug("[ws] startup greeting queued");
1058
+ }
1059
+
1060
  /**
1061
  * Ask the model to generate a response now (after feeding tool results).
1062
  * Serialized: if a response is already in flight we queue this request and
 
1127
  // Abort a queue wait in progress: flag it and wake the poll sleep so
1128
  // `_pollQueue` throws "aborted" and connect() unwinds cleanly.
1129
  this._closed = true;
1130
+ this._userAudioRecorder.reset();
1131
  if (this._queueWake) {
1132
  clearTimeout(this._queueTimer);
1133
  const wake = this._queueWake;
ws/user-audio-recorder.js ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * Keep a bounded copy of the PCM16 frames sent over the realtime WebSocket and
4
+ * turn the backend's VAD boundaries into browser-playable WAV blobs.
5
+ *
6
+ * This records the post-resampling, post-noise-gate signal—not a second raw-mic
7
+ * capture—so replay is as close as the browser can get to the audio delivered
8
+ * to the backend. Nothing leaves the page beyond the existing realtime stream.
9
+ */
10
+
11
+ export const USER_AUDIO_SAMPLE_RATE = 16000;
12
+ const BYTES_PER_SAMPLE = 2;
13
+ const DEFAULT_PREROLL_MS = 5000;
14
+ const DEFAULT_MAX_BUFFER_MS = 120000;
15
+
16
+ /** @param {DataView} view @param {number} offset @param {string} value */
17
+ function _writeAscii(view, offset, value) {
18
+ for (let i = 0; i < value.length; i++) {
19
+ view.setUint8(offset + i, value.charCodeAt(i));
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Wrap little-endian mono PCM16 in a standard WAV container.
25
+ * @param {Uint8Array} pcm
26
+ * @param {number} [sampleRate]
27
+ * @returns {Blob}
28
+ */
29
+ export function pcm16ToWavBlob(pcm, sampleRate = USER_AUDIO_SAMPLE_RATE) {
30
+ const dataLength = pcm.byteLength - (pcm.byteLength % BYTES_PER_SAMPLE);
31
+ const wav = new ArrayBuffer(44 + dataLength);
32
+ const view = new DataView(wav);
33
+ _writeAscii(view, 0, "RIFF");
34
+ view.setUint32(4, 36 + dataLength, true);
35
+ _writeAscii(view, 8, "WAVE");
36
+ _writeAscii(view, 12, "fmt ");
37
+ view.setUint32(16, 16, true);
38
+ view.setUint16(20, 1, true);
39
+ view.setUint16(22, 1, true);
40
+ view.setUint32(24, sampleRate, true);
41
+ view.setUint32(28, sampleRate * BYTES_PER_SAMPLE, true);
42
+ view.setUint16(32, BYTES_PER_SAMPLE, true);
43
+ view.setUint16(34, 16, true);
44
+ _writeAscii(view, 36, "data");
45
+ view.setUint32(40, dataLength, true);
46
+ new Uint8Array(wav, 44).set(pcm.subarray(0, dataLength));
47
+ return new Blob([wav], { type: "audio/wav" });
48
+ }
49
+
50
+ /** @param {Uint8Array} first @param {Uint8Array} second */
51
+ function _concat(first, second) {
52
+ const result = new Uint8Array(first.byteLength + second.byteLength);
53
+ result.set(first, 0);
54
+ result.set(second, first.byteLength);
55
+ return result;
56
+ }
57
+
58
+ export class SentAudioRecorder {
59
+ /**
60
+ * @param {{ sampleRate?: number, preRollMs?: number, maxBufferMs?: number }} [options]
61
+ */
62
+ constructor(options = {}) {
63
+ this.sampleRate = options.sampleRate ?? USER_AUDIO_SAMPLE_RATE;
64
+ this._preRollSamples = Math.round(
65
+ (this.sampleRate * (options.preRollMs ?? DEFAULT_PREROLL_MS)) / 1000,
66
+ );
67
+ this._maxBufferSamples = Math.round(
68
+ (this.sampleRate * (options.maxBufferMs ?? DEFAULT_MAX_BUFFER_MS)) / 1000,
69
+ );
70
+ /** @type {{ startSample: number, endSample: number, bytes: Uint8Array }[]} */
71
+ this._chunks = [];
72
+ this._sentSamples = 0;
73
+ /** @type {{ itemId: string, requestedStartSample: number } | null} */
74
+ this._active = null;
75
+ this._lastItemId = "";
76
+ this._lastItemPcm = new Uint8Array(0);
77
+ }
78
+
79
+ /** Store one PCM16 frame that was actually sent to the backend.
80
+ * @param {ArrayBuffer} buffer */
81
+ append(buffer) {
82
+ const evenLength = buffer.byteLength - (buffer.byteLength % BYTES_PER_SAMPLE);
83
+ if (evenLength <= 0) return;
84
+ const bytes = new Uint8Array(buffer.slice(0, evenLength));
85
+ const startSample = this._sentSamples;
86
+ const endSample = startSample + evenLength / BYTES_PER_SAMPLE;
87
+ this._chunks.push({ startSample, endSample, bytes });
88
+ this._sentSamples = endSample;
89
+ this._prune();
90
+ }
91
+
92
+ /**
93
+ * Remember where the backend says this speech item began. The event normally
94
+ * arrives after confirmation, so the bounded pre-roll retains its onset.
95
+ * @param {{ itemId?: string, audioStartMs?: number }} boundary
96
+ */
97
+ speechStarted(boundary) {
98
+ const itemId = boundary.itemId || `audio_${this._sentSamples}`;
99
+ const requestedStartSample = this._sampleAtMs(boundary.audioStartMs, this._sentSamples);
100
+ this._active = { itemId, requestedStartSample };
101
+ if (itemId !== this._lastItemId) {
102
+ this._lastItemId = itemId;
103
+ this._lastItemPcm = new Uint8Array(0);
104
+ }
105
+ this._prune();
106
+ }
107
+
108
+ /**
109
+ * Finalize the active VAD segment. Reopened segments carrying the same
110
+ * item_id replace the prior recording with their concatenation, matching the
111
+ * chat view's one-row-per-item behavior.
112
+ * @param {{ itemId?: string, audioEndMs?: number }} boundary
113
+ * @returns {{ itemId: string, audio: Blob, durationMs: number, truncated: boolean } | null}
114
+ */
115
+ speechStopped(boundary) {
116
+ const active = this._active;
117
+ if (!active) return null;
118
+ this._active = null;
119
+
120
+ const itemId = boundary.itemId || active.itemId;
121
+ const availableStart = this._chunks[0]?.startSample ?? this._sentSamples;
122
+ const startSample = Math.max(active.requestedStartSample, availableStart);
123
+ let endSample = this._sampleAtMs(boundary.audioEndMs, this._sentSamples);
124
+ if (endSample <= startSample) endSample = this._sentSamples;
125
+ endSample = Math.min(endSample, this._sentSamples);
126
+
127
+ const segment = this._slice(startSample, endSample);
128
+ if (segment.byteLength === 0) {
129
+ this._prune();
130
+ return null;
131
+ }
132
+
133
+ if (itemId !== this._lastItemId) {
134
+ this._lastItemId = itemId;
135
+ this._lastItemPcm = new Uint8Array(0);
136
+ }
137
+ this._lastItemPcm = _concat(this._lastItemPcm, segment);
138
+ const durationMs =
139
+ (this._lastItemPcm.byteLength / BYTES_PER_SAMPLE / this.sampleRate) * 1000;
140
+ const result = {
141
+ itemId,
142
+ audio: pcm16ToWavBlob(this._lastItemPcm, this.sampleRate),
143
+ durationMs,
144
+ truncated: active.requestedStartSample < availableStart,
145
+ };
146
+ this._prune();
147
+ return result;
148
+ }
149
+
150
+ reset() {
151
+ this._chunks = [];
152
+ this._sentSamples = 0;
153
+ this._active = null;
154
+ this._lastItemId = "";
155
+ this._lastItemPcm = new Uint8Array(0);
156
+ }
157
+
158
+ /** @param {number | undefined} ms @param {number} fallback */
159
+ _sampleAtMs(ms, fallback) {
160
+ if (!Number.isFinite(ms) || Number(ms) < 0) return fallback;
161
+ return Math.max(
162
+ 0,
163
+ Math.min(Math.round((Number(ms) * this.sampleRate) / 1000), this._sentSamples),
164
+ );
165
+ }
166
+
167
+ /** @param {number} startSample @param {number} endSample */
168
+ _slice(startSample, endSample) {
169
+ /** @type {Uint8Array[]} */
170
+ const parts = [];
171
+ let length = 0;
172
+ for (const chunk of this._chunks) {
173
+ const overlapStart = Math.max(startSample, chunk.startSample);
174
+ const overlapEnd = Math.min(endSample, chunk.endSample);
175
+ if (overlapEnd <= overlapStart) continue;
176
+ const from = (overlapStart - chunk.startSample) * BYTES_PER_SAMPLE;
177
+ const to = (overlapEnd - chunk.startSample) * BYTES_PER_SAMPLE;
178
+ const part = chunk.bytes.slice(from, to);
179
+ parts.push(part);
180
+ length += part.byteLength;
181
+ }
182
+ const result = new Uint8Array(length);
183
+ let offset = 0;
184
+ for (const part of parts) {
185
+ result.set(part, offset);
186
+ offset += part.byteLength;
187
+ }
188
+ return result;
189
+ }
190
+
191
+ _prune() {
192
+ const hardFloor = Math.max(0, this._sentSamples - this._maxBufferSamples);
193
+ const softFloor = this._active
194
+ ? this._active.requestedStartSample
195
+ : Math.max(0, this._sentSamples - this._preRollSamples);
196
+ const floor = Math.max(hardFloor, softFloor);
197
+ while (this._chunks.length && this._chunks[0].endSample <= floor) {
198
+ this._chunks.shift();
199
+ }
200
+ }
201
+ }