Spaces:
Running
Running
Add explicit Join gate, your-turn state, and at-capacity modal
Browse filesAfter waiting in line the conversation no longer auto-starts: reaching the front
holds a slot and shows a "your turn" orb with a "Join now" countdown button, so a
slot is never spent on someone who stepped away, and the click is a fresh gesture
that re-arms the AudioContext on iOS. Leaving or letting the countdown lapse
refunds the reserved budget.
When the whole waiting line is full, a warm "we're at capacity" modal (reusing the
limit-modal shell) explains the wait instead of a terse error. Queue captions and
retry messages reworded to be kinder.
- index.html +8 -3
- main.js +88 -12
- style.css +42 -3
- ui/account.js +11 -0
- ws/s2s-ws-client.js +82 -9
index.html
CHANGED
|
@@ -132,9 +132,14 @@
|
|
| 132 |
|
| 133 |
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
|
| 134 |
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</main>
|
| 139 |
|
| 140 |
<footer class="footer">
|
|
|
|
| 132 |
|
| 133 |
<p id="circle-caption" class="circle-caption" role="status">Tap to start</p>
|
| 134 |
|
| 135 |
+
<div id="queue-actions" class="queue-actions" hidden>
|
| 136 |
+
<button id="join-queue-btn" class="join-queue-btn" type="button" hidden>
|
| 137 |
+
Join now
|
| 138 |
+
</button>
|
| 139 |
+
<button id="leave-queue-btn" class="leave-queue-btn" type="button" hidden>
|
| 140 |
+
Leave queue
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
</main>
|
| 144 |
|
| 145 |
<footer class="footer">
|
main.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
* client owns its own AudioContext (no `attachOutputTrack`), so we hand
|
| 14 |
* it the MediaStream directly.
|
| 15 |
*
|
| 16 |
-
* @typedef {"idle" | "connecting" | "queued" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
|
| 17 |
*/
|
| 18 |
|
| 19 |
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
|
|
@@ -151,7 +151,8 @@ function saveTools() {
|
|
| 151 |
const STATE_VIEWS = {
|
| 152 |
idle: { caption: "Tap to start", disabled: false },
|
| 153 |
connecting: { caption: "Connecting", disabled: true },
|
| 154 |
-
queued: { caption: "
|
|
|
|
| 155 |
listening: { caption: "", disabled: false },
|
| 156 |
"user-speaking": { caption: "", disabled: false },
|
| 157 |
processing: { caption: "", disabled: false },
|
|
@@ -164,6 +165,7 @@ const STATE_CLASS = {
|
|
| 164 |
idle: "state-idle",
|
| 165 |
connecting: "state-connecting",
|
| 166 |
queued: "state-queued",
|
|
|
|
| 167 |
listening: "state-listening",
|
| 168 |
"user-speaking": "state-user-speaking",
|
| 169 |
processing: "state-processing",
|
|
@@ -184,6 +186,10 @@ const orbWrap = $(".orb-wrap");
|
|
| 184 |
const micBtn = $("#mic-btn");
|
| 185 |
/** @type {HTMLButtonElement} */
|
| 186 |
const stopBtn = $("#stop-btn");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
/** @type {HTMLButtonElement} */
|
| 188 |
const leaveQueueBtn = $("#leave-queue-btn");
|
| 189 |
|
|
@@ -343,10 +349,16 @@ function setState(next) {
|
|
| 343 |
micBtn.tabIndex = live ? 0 : -1;
|
| 344 |
stopBtn.tabIndex = live ? 0 : -1;
|
| 345 |
|
| 346 |
-
//
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
updateRestartAvailability();
|
| 352 |
}
|
|
@@ -355,7 +367,8 @@ function updateRestartAvailability() {
|
|
| 355 |
// Restart works from any settled state — it tears down a live call (if any)
|
| 356 |
// and reconnects with the current settings. Only block while mid-connect or
|
| 357 |
// while waiting in the queue (restarting from there would just re-queue).
|
| 358 |
-
restartBtn.disabled =
|
|
|
|
| 359 |
restartHint.hidden = false;
|
| 360 |
restartHint.textContent = LIVE_STATES.has(currentState)
|
| 361 |
? "Reconnects now with the settings above."
|
|
@@ -989,13 +1002,21 @@ async function handleStartError(err) {
|
|
| 989 |
// The user left the queue (close() aborted the wait): teardown already reset
|
| 990 |
// the UI to idle, so there's nothing to report.
|
| 991 |
if (err && err.code === "aborted") return;
|
| 992 |
-
// The
|
| 993 |
-
|
| 994 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
await teardown();
|
| 996 |
setState("error");
|
| 997 |
setCaption(
|
| 998 |
-
err.code === "
|
|
|
|
|
|
|
| 999 |
"error",
|
| 1000 |
);
|
| 1001 |
return;
|
|
@@ -1025,6 +1046,13 @@ leaveQueueBtn.addEventListener("click", async () => {
|
|
| 1025 |
await teardown();
|
| 1026 |
});
|
| 1027 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
const MIC_CONSTRAINTS = {
|
| 1029 |
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
| 1030 |
};
|
|
@@ -1053,7 +1081,38 @@ async function acquireMicStream() {
|
|
| 1053 |
/** @param {number} position Update the queued caption ("You're #N in line"). */
|
| 1054 |
function onQueuePosition(position) {
|
| 1055 |
const n = Number(position) || 0;
|
| 1056 |
-
setCaption(n > 0 ? `You're #${n} in line` : "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
}
|
| 1058 |
|
| 1059 |
/**
|
|
@@ -1109,6 +1168,19 @@ async function doStart(audioContext = null) {
|
|
| 1109 |
onQueuePosition(position);
|
| 1110 |
});
|
| 1111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
c.addEventListener("status", (e) => {
|
| 1113 |
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
| 1114 |
onClientStatus(detail.status);
|
|
@@ -1260,6 +1332,9 @@ function onClientStatus(status) {
|
|
| 1260 |
case "queued":
|
| 1261 |
setState("queued");
|
| 1262 |
break;
|
|
|
|
|
|
|
|
|
|
| 1263 |
case "connected":
|
| 1264 |
setState("listening");
|
| 1265 |
break;
|
|
@@ -1283,6 +1358,7 @@ function onClientStatus(status) {
|
|
| 1283 |
|
| 1284 |
async function teardown() {
|
| 1285 |
stopHeartbeat();
|
|
|
|
| 1286 |
endTrackedSession();
|
| 1287 |
endQueueTicket();
|
| 1288 |
chat.reset({ dismiss: true });
|
|
|
|
| 13 |
* client owns its own AudioContext (no `attachOutputTrack`), so we hand
|
| 14 |
* it the MediaStream directly.
|
| 15 |
*
|
| 16 |
+
* @typedef {"idle" | "connecting" | "queued" | "your-turn" | "listening" | "user-speaking" | "processing" | "ai-speaking" | "error"} AppState
|
| 17 |
*/
|
| 18 |
|
| 19 |
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
|
|
|
|
| 151 |
const STATE_VIEWS = {
|
| 152 |
idle: { caption: "Tap to start", disabled: false },
|
| 153 |
connecting: { caption: "Connecting", disabled: true },
|
| 154 |
+
queued: { caption: "Finding you a spot…", disabled: true },
|
| 155 |
+
"your-turn": { caption: "You're up! 🎉", disabled: true },
|
| 156 |
listening: { caption: "", disabled: false },
|
| 157 |
"user-speaking": { caption: "", disabled: false },
|
| 158 |
processing: { caption: "", disabled: false },
|
|
|
|
| 165 |
idle: "state-idle",
|
| 166 |
connecting: "state-connecting",
|
| 167 |
queued: "state-queued",
|
| 168 |
+
"your-turn": "state-your-turn",
|
| 169 |
listening: "state-listening",
|
| 170 |
"user-speaking": "state-user-speaking",
|
| 171 |
processing: "state-processing",
|
|
|
|
| 186 |
const micBtn = $("#mic-btn");
|
| 187 |
/** @type {HTMLButtonElement} */
|
| 188 |
const stopBtn = $("#stop-btn");
|
| 189 |
+
/** @type {HTMLElement} */
|
| 190 |
+
const queueActions = $("#queue-actions");
|
| 191 |
+
/** @type {HTMLButtonElement} */
|
| 192 |
+
const joinQueueBtn = $("#join-queue-btn");
|
| 193 |
/** @type {HTMLButtonElement} */
|
| 194 |
const leaveQueueBtn = $("#leave-queue-btn");
|
| 195 |
|
|
|
|
| 349 |
micBtn.tabIndex = live ? 0 : -1;
|
| 350 |
stopBtn.tabIndex = live ? 0 : -1;
|
| 351 |
|
| 352 |
+
// Queue affordances: "Leave queue" whenever we're in line; "Join now" only once
|
| 353 |
+
// it's our turn (a slot is held for us). Both live under #queue-actions.
|
| 354 |
+
const yourTurn = next === "your-turn";
|
| 355 |
+
const inLine = next === "queued" || yourTurn;
|
| 356 |
+
queueActions.hidden = !inLine;
|
| 357 |
+
joinQueueBtn.hidden = !yourTurn;
|
| 358 |
+
joinQueueBtn.tabIndex = yourTurn ? 0 : -1;
|
| 359 |
+
leaveQueueBtn.hidden = !inLine;
|
| 360 |
+
leaveQueueBtn.tabIndex = inLine ? 0 : -1;
|
| 361 |
+
if (!yourTurn) stopJoinCountdown();
|
| 362 |
|
| 363 |
updateRestartAvailability();
|
| 364 |
}
|
|
|
|
| 367 |
// Restart works from any settled state — it tears down a live call (if any)
|
| 368 |
// and reconnects with the current settings. Only block while mid-connect or
|
| 369 |
// while waiting in the queue (restarting from there would just re-queue).
|
| 370 |
+
restartBtn.disabled =
|
| 371 |
+
currentState === "connecting" || currentState === "queued" || currentState === "your-turn";
|
| 372 |
restartHint.hidden = false;
|
| 373 |
restartHint.textContent = LIVE_STATES.has(currentState)
|
| 374 |
? "Reconnects now with the settings above."
|
|
|
|
| 1002 |
// The user left the queue (close() aborted the wait): teardown already reset
|
| 1003 |
// the UI to idle, so there's nothing to report.
|
| 1004 |
if (err && err.code === "aborted") return;
|
| 1005 |
+
// The whole waiting line is full: a warm, reassuring modal rather than an error.
|
| 1006 |
+
if (err && err.code === "queue-full") {
|
| 1007 |
+
await teardown();
|
| 1008 |
+
account.showBusy();
|
| 1009 |
+
return;
|
| 1010 |
+
}
|
| 1011 |
+
// Our place lapsed (ticket reaped, or the join window ran out). Recoverable, not
|
| 1012 |
+
// a fault: land on the retry state with a kind, plain-language reason.
|
| 1013 |
+
if (err && (err.code === "queue-expired" || err.code === "join-expired")) {
|
| 1014 |
await teardown();
|
| 1015 |
setState("error");
|
| 1016 |
setCaption(
|
| 1017 |
+
err.code === "join-expired"
|
| 1018 |
+
? "Your spot expired. Tap to rejoin."
|
| 1019 |
+
: "That took a while. Tap to rejoin.",
|
| 1020 |
"error",
|
| 1021 |
);
|
| 1022 |
return;
|
|
|
|
| 1046 |
await teardown();
|
| 1047 |
});
|
| 1048 |
|
| 1049 |
+
// "Join now": accept the held slot. The click is a user gesture, so the client
|
| 1050 |
+
// re-resumes the AudioContext here (iOS) before dialing.
|
| 1051 |
+
joinQueueBtn.addEventListener("click", () => {
|
| 1052 |
+
stopJoinCountdown();
|
| 1053 |
+
if (client) client.join();
|
| 1054 |
+
});
|
| 1055 |
+
|
| 1056 |
const MIC_CONSTRAINTS = {
|
| 1057 |
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
| 1058 |
};
|
|
|
|
| 1081 |
/** @param {number} position Update the queued caption ("You're #N in line"). */
|
| 1082 |
function onQueuePosition(position) {
|
| 1083 |
const n = Number(position) || 0;
|
| 1084 |
+
setCaption(n > 0 ? `You're #${n} in line` : "Finding you a spot…", "muted");
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
// ── "Your turn" join countdown ──────────────────────────────────────────────
|
| 1088 |
+
// While a slot is held for us, show how long is left to accept it. The client's
|
| 1089 |
+
// join gate expires just before the load balancer reclaims the slot.
|
| 1090 |
+
let joinCountdownTimer = 0;
|
| 1091 |
+
|
| 1092 |
+
/** @param {number} sec */
|
| 1093 |
+
function startJoinCountdown(sec) {
|
| 1094 |
+
stopJoinCountdown();
|
| 1095 |
+
let left = Math.max(0, Math.floor(sec));
|
| 1096 |
+
const paint = () => {
|
| 1097 |
+
joinQueueBtn.textContent = left > 0 ? `Join now (${left}s)` : "Join now";
|
| 1098 |
+
};
|
| 1099 |
+
paint();
|
| 1100 |
+
joinCountdownTimer = window.setInterval(() => {
|
| 1101 |
+
left -= 1;
|
| 1102 |
+
if (left <= 0) {
|
| 1103 |
+
stopJoinCountdown();
|
| 1104 |
+
joinQueueBtn.textContent = "Join now";
|
| 1105 |
+
return;
|
| 1106 |
+
}
|
| 1107 |
+
paint();
|
| 1108 |
+
}, 1000);
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
function stopJoinCountdown() {
|
| 1112 |
+
if (joinCountdownTimer) {
|
| 1113 |
+
clearInterval(joinCountdownTimer);
|
| 1114 |
+
joinCountdownTimer = 0;
|
| 1115 |
+
}
|
| 1116 |
}
|
| 1117 |
|
| 1118 |
/**
|
|
|
|
| 1168 |
onQueuePosition(position);
|
| 1169 |
});
|
| 1170 |
|
| 1171 |
+
c.addEventListener("ready-to-join", (e) => {
|
| 1172 |
+
const { info, expiresSec } = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo; expiresSec: number }>} */ (e).detail;
|
| 1173 |
+
// A slot is held for us. We're out of the queue now, so drop the ticket ref.
|
| 1174 |
+
// Track the granted session id already so that leaving (or letting the timer
|
| 1175 |
+
// lapse) refunds the budget the server reserved at claim, even before we dial.
|
| 1176 |
+
queuedTicketId = "";
|
| 1177 |
+
if (info?.sessionId) {
|
| 1178 |
+
trackedSessionId = info.sessionId;
|
| 1179 |
+
trackedTier = info.tier || "anon";
|
| 1180 |
+
}
|
| 1181 |
+
startJoinCountdown(expiresSec);
|
| 1182 |
+
});
|
| 1183 |
+
|
| 1184 |
c.addEventListener("status", (e) => {
|
| 1185 |
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
| 1186 |
onClientStatus(detail.status);
|
|
|
|
| 1332 |
case "queued":
|
| 1333 |
setState("queued");
|
| 1334 |
break;
|
| 1335 |
+
case "your-turn":
|
| 1336 |
+
setState("your-turn");
|
| 1337 |
+
break;
|
| 1338 |
case "connected":
|
| 1339 |
setState("listening");
|
| 1340 |
break;
|
|
|
|
| 1358 |
|
| 1359 |
async function teardown() {
|
| 1360 |
stopHeartbeat();
|
| 1361 |
+
stopJoinCountdown();
|
| 1362 |
endTrackedSession();
|
| 1363 |
endQueueTicket();
|
| 1364 |
chat.reset({ dismiss: true });
|
style.css
CHANGED
|
@@ -847,10 +847,45 @@ a:hover {
|
|
| 847 |
letter-spacing: 0.08em;
|
| 848 |
}
|
| 849 |
|
| 850 |
-
/*
|
| 851 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
.leave-queue-btn {
|
| 853 |
-
margin-top: 14px;
|
| 854 |
padding: 7px 16px;
|
| 855 |
font-family: var(--font-mono);
|
| 856 |
font-size: 11px;
|
|
@@ -1125,6 +1160,10 @@ body.booting *::after {
|
|
| 1125 |
.circle.state-queued { --glow: #94a3b8; }
|
| 1126 |
.circle.state-queued .ind-spinner { animation-duration: 2.4s; }
|
| 1127 |
.circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1128 |
.circle.state-listening,
|
| 1129 |
.circle.state-user-speaking { --glow: var(--listening); }
|
| 1130 |
.circle.state-processing { --glow: var(--processing); }
|
|
|
|
| 847 |
letter-spacing: 0.08em;
|
| 848 |
}
|
| 849 |
|
| 850 |
+
/* Queue actions sit under the caption: "Join now" (primary, only when it's your
|
| 851 |
+
* turn) stacked above the quiet "Leave queue" escape hatch. */
|
| 852 |
+
.queue-actions {
|
| 853 |
+
margin-top: 16px;
|
| 854 |
+
display: flex;
|
| 855 |
+
flex-direction: column;
|
| 856 |
+
align-items: center;
|
| 857 |
+
gap: 10px;
|
| 858 |
+
}
|
| 859 |
+
.queue-actions[hidden] { display: none; }
|
| 860 |
+
|
| 861 |
+
/* "Join now": the one call to action in the queue flow, so it reads as inviting
|
| 862 |
+
* (filled accent) while everything around it stays quiet. */
|
| 863 |
+
.join-queue-btn {
|
| 864 |
+
padding: 10px 26px;
|
| 865 |
+
font-family: var(--font-mono);
|
| 866 |
+
font-size: 12px;
|
| 867 |
+
font-weight: 600;
|
| 868 |
+
letter-spacing: 0.1em;
|
| 869 |
+
text-transform: uppercase;
|
| 870 |
+
color: #0b0b10;
|
| 871 |
+
background: var(--accent);
|
| 872 |
+
border: none;
|
| 873 |
+
border-radius: 999px;
|
| 874 |
+
cursor: pointer;
|
| 875 |
+
font-variant-numeric: tabular-nums;
|
| 876 |
+
transition: transform 0.12s ease, filter 0.2s ease;
|
| 877 |
+
}
|
| 878 |
+
.join-queue-btn:hover { filter: brightness(1.08); }
|
| 879 |
+
.join-queue-btn:active { transform: scale(0.97); }
|
| 880 |
+
.join-queue-btn:focus-visible {
|
| 881 |
+
outline: 2px solid var(--accent);
|
| 882 |
+
outline-offset: 3px;
|
| 883 |
+
}
|
| 884 |
+
.join-queue-btn[hidden] { display: none; }
|
| 885 |
+
|
| 886 |
+
/* "Leave queue": a quiet outlined pill, understated so it reads as an escape
|
| 887 |
+
* hatch, not a CTA. */
|
| 888 |
.leave-queue-btn {
|
|
|
|
| 889 |
padding: 7px 16px;
|
| 890 |
font-family: var(--font-mono);
|
| 891 |
font-size: 11px;
|
|
|
|
| 1160 |
.circle.state-queued { --glow: #94a3b8; }
|
| 1161 |
.circle.state-queued .ind-spinner { animation-duration: 2.4s; }
|
| 1162 |
.circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; }
|
| 1163 |
+
/* Your turn: a slot is held for you — the orb warms to the accent and breathes a
|
| 1164 |
+
* little quicker, an invitation to join. */
|
| 1165 |
+
.circle.state-your-turn { --glow: var(--accent); }
|
| 1166 |
+
.circle.state-your-turn .circle-ring { animation: breathe 1.6s ease-in-out infinite; }
|
| 1167 |
.circle.state-listening,
|
| 1168 |
.circle.state-user-speaking { --glow: var(--listening); }
|
| 1169 |
.circle.state-processing { --glow: var(--processing); }
|
ui/account.js
CHANGED
|
@@ -175,4 +175,15 @@ export class Account {
|
|
| 175 |
}
|
| 176 |
if (!this._modal.open) this._modal.showModal();
|
| 177 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
|
|
|
| 175 |
}
|
| 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() {
|
| 182 |
+
this._modalTitle.textContent = "We're at capacity right now";
|
| 183 |
+
this._modalMsg.textContent =
|
| 184 |
+
"Lots of people are trying the demo, so every spot and the whole waiting line are full. Please give it a minute and try again. Thanks so much for your patience!";
|
| 185 |
+
this._modalNote.textContent = "A spot usually opens up within a few minutes.";
|
| 186 |
+
this._modalCta.hidden = true;
|
| 187 |
+
if (!this._modal.open) this._modal.showModal();
|
| 188 |
+
}
|
| 189 |
}
|
ws/s2s-ws-client.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
| 23 |
* sees high-level lifecycle events (`status`, `transcript`, `error`,
|
| 24 |
* `session`), the same shape as the WebRTC client.
|
| 25 |
*
|
| 26 |
-
* @typedef {"idle" | "creating-session" | "queued" | "
|
| 27 |
-
* "user-speaking" | "processing" | "ai-speaking" |
|
|
|
|
| 28 |
* } WsStatus
|
| 29 |
*
|
| 30 |
* @typedef {Object} WsSessionInfo
|
|
@@ -135,6 +136,15 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 135 |
this._queueWake = null;
|
| 136 |
/** @type {ReturnType<typeof setTimeout> | 0} */
|
| 137 |
this._queueTimer = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
/** @type {NoiseGate} Mic noise gate; off by default. */
|
| 139 |
this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
|
| 140 |
/** @type {WebSocket | null} */
|
|
@@ -237,10 +247,17 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 237 |
throw new Error("No session endpoint or direct URL configured");
|
| 238 |
}
|
| 239 |
this._setStatus("creating-session");
|
| 240 |
-
const
|
| 241 |
if (this._closed) throw _codedError("connect aborted", "aborted");
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
this._setStatus("connecting");
|
| 245 |
}
|
| 246 |
|
|
@@ -259,16 +276,62 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 259 |
|
| 260 |
/**
|
| 261 |
* POST the session handshake; if the pool is busy, wait in the queue (polling
|
| 262 |
-
* position) until a slot is claimed. Resolves to a grant
|
| 263 |
-
*
|
|
|
|
| 264 |
*/
|
| 265 |
async _createSessionOrQueue() {
|
| 266 |
const first = await this._postSession();
|
| 267 |
if (first.state === "queued") {
|
| 268 |
this._setStatus("queued");
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
/**
|
|
@@ -978,6 +1041,16 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 978 |
this._queueWake = null;
|
| 979 |
wake();
|
| 980 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
this._visualiser?.stop();
|
| 982 |
this._visualiser = null;
|
| 983 |
try {
|
|
|
|
| 23 |
* sees high-level lifecycle events (`status`, `transcript`, `error`,
|
| 24 |
* `session`), the same shape as the WebRTC client.
|
| 25 |
*
|
| 26 |
+
* @typedef {"idle" | "creating-session" | "queued" | "your-turn" | "connecting" |
|
| 27 |
+
* "connected" | "user-speaking" | "processing" | "ai-speaking" |
|
| 28 |
+
* "closed" | "error"
|
| 29 |
* } WsStatus
|
| 30 |
*
|
| 31 |
* @typedef {Object} WsSessionInfo
|
|
|
|
| 136 |
this._queueWake = null;
|
| 137 |
/** @type {ReturnType<typeof setTimeout> | 0} */
|
| 138 |
this._queueTimer = 0;
|
| 139 |
+
// Join gate: after waiting in line the caller must explicitly `join()` before
|
| 140 |
+
// we dial, so a slot isn't spent on someone who walked away. Resolved by
|
| 141 |
+
// join(), rejected on timeout (the LB reclaims the slot) or close().
|
| 142 |
+
/** @type {(() => void) | null} */
|
| 143 |
+
this._joinResolve = null;
|
| 144 |
+
/** @type {((err: Error) => void) | null} */
|
| 145 |
+
this._joinReject = null;
|
| 146 |
+
/** @type {ReturnType<typeof setTimeout> | 0} */
|
| 147 |
+
this._joinTimer = 0;
|
| 148 |
/** @type {NoiseGate} Mic noise gate; off by default. */
|
| 149 |
this._noiseGate = options.noiseGate ?? { enabled: false, thresholdDb: -45 };
|
| 150 |
/** @type {WebSocket | null} */
|
|
|
|
| 247 |
throw new Error("No session endpoint or direct URL configured");
|
| 248 |
}
|
| 249 |
this._setStatus("creating-session");
|
| 250 |
+
const { grant, waited } = await this._createSessionOrQueue();
|
| 251 |
if (this._closed) throw _codedError("connect aborted", "aborted");
|
| 252 |
+
// If we waited in line, don't dial until the user explicitly joins — this
|
| 253 |
+
// keeps a freed slot from being spent on someone who stepped away, and the
|
| 254 |
+
// click is a fresh gesture (re-arms the AudioContext on iOS).
|
| 255 |
+
if (waited) {
|
| 256 |
+
await this._awaitJoin(grant);
|
| 257 |
+
if (this._closed) throw _codedError("connect aborted", "aborted");
|
| 258 |
+
}
|
| 259 |
+
this.dispatchEvent(new CustomEvent("session", { detail: { info: grant } }));
|
| 260 |
+
connectUrl = grant.connectUrl;
|
| 261 |
this._setStatus("connecting");
|
| 262 |
}
|
| 263 |
|
|
|
|
| 276 |
|
| 277 |
/**
|
| 278 |
* POST the session handshake; if the pool is busy, wait in the queue (polling
|
| 279 |
+
* position) until a slot is claimed. Resolves to a grant plus whether we had to
|
| 280 |
+
* wait (which decides if an explicit join is required before dialing).
|
| 281 |
+
* @returns {Promise<{ grant: WsSessionInfo, waited: boolean }>}
|
| 282 |
*/
|
| 283 |
async _createSessionOrQueue() {
|
| 284 |
const first = await this._postSession();
|
| 285 |
if (first.state === "queued") {
|
| 286 |
this._setStatus("queued");
|
| 287 |
+
const grant = await this._pollQueue(first);
|
| 288 |
+
return { grant, waited: true };
|
| 289 |
+
}
|
| 290 |
+
return { grant: first.grant, waited: false };
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/**
|
| 294 |
+
* Hold at the front of the line until the user clicks join (resolves the gate)
|
| 295 |
+
* or the grant lapses. Announces "your-turn" + a deadline the UI counts down.
|
| 296 |
+
* @param {WsSessionInfo} grant
|
| 297 |
+
* @returns {Promise<void>}
|
| 298 |
+
*/
|
| 299 |
+
_awaitJoin(grant) {
|
| 300 |
+
// The LB reclaims an unclaimed slot at its pending timeout; expire the gate a
|
| 301 |
+
// touch earlier so we never dial a session the LB just reaped.
|
| 302 |
+
const windowS = Math.max(3, (grant.pendingTimeoutS || 60) - 3);
|
| 303 |
+
this._setStatus("your-turn");
|
| 304 |
+
this.dispatchEvent(
|
| 305 |
+
new CustomEvent("ready-to-join", { detail: { info: grant, expiresSec: windowS } }),
|
| 306 |
+
);
|
| 307 |
+
return new Promise((resolve, reject) => {
|
| 308 |
+
this._joinResolve = resolve;
|
| 309 |
+
this._joinReject = reject;
|
| 310 |
+
this._joinTimer = setTimeout(() => {
|
| 311 |
+
this._joinResolve = null;
|
| 312 |
+
this._joinReject = null;
|
| 313 |
+
reject(_codedError("Your spot expired", "join-expired"));
|
| 314 |
+
}, windowS * 1000);
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/** Accept the held slot and let connect() proceed to dial. Called from the
|
| 319 |
+
* "Join now" click, so it's a user gesture: re-resume the AudioContext, which
|
| 320 |
+
* iOS may have suspended while we waited. */
|
| 321 |
+
join() {
|
| 322 |
+
if (this._joinTimer) {
|
| 323 |
+
clearTimeout(this._joinTimer);
|
| 324 |
+
this._joinTimer = 0;
|
| 325 |
}
|
| 326 |
+
try {
|
| 327 |
+
void this.options.audioContext?.resume();
|
| 328 |
+
} catch {
|
| 329 |
+
// best-effort; _setupAudio resumes again
|
| 330 |
+
}
|
| 331 |
+
const resolve = this._joinResolve;
|
| 332 |
+
this._joinResolve = null;
|
| 333 |
+
this._joinReject = null;
|
| 334 |
+
resolve?.();
|
| 335 |
}
|
| 336 |
|
| 337 |
/**
|
|
|
|
| 1041 |
this._queueWake = null;
|
| 1042 |
wake();
|
| 1043 |
}
|
| 1044 |
+
if (this._joinTimer) {
|
| 1045 |
+
clearTimeout(this._joinTimer);
|
| 1046 |
+
this._joinTimer = 0;
|
| 1047 |
+
}
|
| 1048 |
+
if (this._joinReject) {
|
| 1049 |
+
const reject = this._joinReject;
|
| 1050 |
+
this._joinResolve = null;
|
| 1051 |
+
this._joinReject = null;
|
| 1052 |
+
reject(_codedError("join aborted", "aborted"));
|
| 1053 |
+
}
|
| 1054 |
this._visualiser?.stop();
|
| 1055 |
this._visualiser = null;
|
| 1056 |
try {
|