Spaces:
Running
Running
Port authenticated voice session UX
Browse files- README.md +15 -1
- auth.py +13 -0
- index.html +18 -0
- main.js +169 -11
- server.py +68 -1
- style.css +72 -0
- ui/account.js +28 -2
- ui/chat.js +226 -15
- ws/s2s-ws-client.js +94 -1
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 1085 |
-
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 339 |
-
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
| 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 |
+
}
|