Add user audio replay
Browse files- main.js +22 -5
- style.css +23 -0
- ui/chat.js +105 -12
- ws/s2s-ws-client.js +21 -0
- ws/user-audio-recorder.js +198 -0
main.js
CHANGED
|
@@ -378,7 +378,13 @@ function pushToolsToSession() {
|
|
| 378 |
// ── Chat view ───────────────────────────────────────────────────────────────
|
| 379 |
// Owns the history panel, the ephemeral bubbles, and all transcript/tool
|
| 380 |
// streaming state. The client's events are forwarded to its on* methods.
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
// ── Account / limiter ─────────────────────────────────────────────────────
|
| 384 |
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
|
|
@@ -400,6 +406,15 @@ let client = null;
|
|
| 400 |
let micStream = null;
|
| 401 |
let micMuted = false;
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
/** @param {AppState} next */
|
| 404 |
function setState(next) {
|
| 405 |
currentState = next;
|
|
@@ -1209,10 +1224,7 @@ async function handleStartError(err) {
|
|
| 1209 |
micBtn.addEventListener("click", () => {
|
| 1210 |
if (!micStream || !client) return;
|
| 1211 |
micMuted = !micMuted;
|
| 1212 |
-
|
| 1213 |
-
track.enabled = !micMuted;
|
| 1214 |
-
}
|
| 1215 |
-
client.setMuted(micMuted);
|
| 1216 |
micBtn.classList.toggle("muted", micMuted);
|
| 1217 |
micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
|
| 1218 |
micBtn.title = micMuted ? "Unmute" : "Mute";
|
|
@@ -1457,6 +1469,7 @@ async function doStart(audioContext = null) {
|
|
| 1457 |
...common,
|
| 1458 |
});
|
| 1459 |
client = c;
|
|
|
|
| 1460 |
|
| 1461 |
c.addEventListener("queue", (e) => {
|
| 1462 |
const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
|
|
@@ -1485,6 +1498,10 @@ async function doStart(audioContext = null) {
|
|
| 1485 |
const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
|
| 1486 |
chat.onTranscript(d);
|
| 1487 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1488 |
|
| 1489 |
c.addEventListener("response-finished", (e) => {
|
| 1490 |
const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
|
|
|
|
| 378 |
// ── Chat view ───────────────────────────────────────────────────────────────
|
| 379 |
// Owns the history panel, the ephemeral bubbles, and all transcript/tool
|
| 380 |
// streaming state. The client's events are forwarded to its on* methods.
|
| 381 |
+
let userAudioReplaying = false;
|
| 382 |
+
const chat = new ChatView({
|
| 383 |
+
onUserAudioPlaybackChange(playing) {
|
| 384 |
+
userAudioReplaying = playing;
|
| 385 |
+
syncMicMuteState();
|
| 386 |
+
},
|
| 387 |
+
});
|
| 388 |
|
| 389 |
// ── Account / limiter ─────────────────────────────────────────────────────
|
| 390 |
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
|
|
|
|
| 406 |
let micStream = null;
|
| 407 |
let micMuted = false;
|
| 408 |
|
| 409 |
+
/** Apply both the user's mute choice and the temporary replay guard. */
|
| 410 |
+
function syncMicMuteState() {
|
| 411 |
+
const muted = micMuted || userAudioReplaying;
|
| 412 |
+
for (const track of micStream?.getAudioTracks() ?? []) {
|
| 413 |
+
track.enabled = !muted;
|
| 414 |
+
}
|
| 415 |
+
client?.setMuted(muted);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
/** @param {AppState} next */
|
| 419 |
function setState(next) {
|
| 420 |
currentState = next;
|
|
|
|
| 1224 |
micBtn.addEventListener("click", () => {
|
| 1225 |
if (!micStream || !client) return;
|
| 1226 |
micMuted = !micMuted;
|
| 1227 |
+
syncMicMuteState();
|
|
|
|
|
|
|
|
|
|
| 1228 |
micBtn.classList.toggle("muted", micMuted);
|
| 1229 |
micBtn.setAttribute("aria-label", micMuted ? "Unmute" : "Mute");
|
| 1230 |
micBtn.title = micMuted ? "Unmute" : "Mute";
|
|
|
|
| 1469 |
...common,
|
| 1470 |
});
|
| 1471 |
client = c;
|
| 1472 |
+
c.setMuted(micMuted || userAudioReplaying);
|
| 1473 |
|
| 1474 |
c.addEventListener("queue", (e) => {
|
| 1475 |
const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
|
|
|
|
| 1498 |
const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
|
| 1499 |
chat.onTranscript(d);
|
| 1500 |
});
|
| 1501 |
+
c.addEventListener("user-audio", (e) => {
|
| 1502 |
+
const detail = /** @type {CustomEvent<{ itemId?: string; audio: Blob; durationMs?: number; truncated?: boolean }>} */ (e).detail;
|
| 1503 |
+
chat.onUserAudio(detail);
|
| 1504 |
+
});
|
| 1505 |
|
| 1506 |
c.addEventListener("response-finished", (e) => {
|
| 1507 |
const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
|
style.css
CHANGED
|
@@ -1887,6 +1887,29 @@ body.cam-on .footer {
|
|
| 1887 |
* distinguishing, so the panel reads as one quiet column. */
|
| 1888 |
.hist-msg.user .hist-body.partial { opacity: 0.65; }
|
| 1889 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1890 |
/* ─── Tool call history item ─────────────────────────────────────────── */
|
| 1891 |
|
| 1892 |
.hist-tool-header {
|
|
|
|
| 1887 |
* distinguishing, so the panel reads as one quiet column. */
|
| 1888 |
.hist-msg.user .hist-body.partial { opacity: 0.65; }
|
| 1889 |
|
| 1890 |
+
.hist-audio {
|
| 1891 |
+
display: flex;
|
| 1892 |
+
flex-direction: column;
|
| 1893 |
+
gap: 5px;
|
| 1894 |
+
padding: 8px 10px;
|
| 1895 |
+
border: 1px solid color-mix(in srgb, var(--voice-user) 22%, var(--border));
|
| 1896 |
+
border-radius: var(--radius-md);
|
| 1897 |
+
background: color-mix(in srgb, var(--voice-user) 6%, var(--bg-elev-2));
|
| 1898 |
+
}
|
| 1899 |
+
.hist-audio-label {
|
| 1900 |
+
font-family: var(--font-mono);
|
| 1901 |
+
font-size: 9px;
|
| 1902 |
+
letter-spacing: 0.06em;
|
| 1903 |
+
text-transform: uppercase;
|
| 1904 |
+
color: color-mix(in srgb, var(--voice-user) 68%, var(--text-faint));
|
| 1905 |
+
}
|
| 1906 |
+
.hist-audio audio {
|
| 1907 |
+
display: block;
|
| 1908 |
+
width: min(260px, 100%);
|
| 1909 |
+
height: 34px;
|
| 1910 |
+
color-scheme: light dark;
|
| 1911 |
+
}
|
| 1912 |
+
|
| 1913 |
/* ─── Tool call history item ─────────────────────────────────────────── */
|
| 1914 |
|
| 1915 |
.hist-tool-header {
|
ui/chat.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
// @ts-check
|
| 2 |
/**
|
| 3 |
* ChatView — owns the whole conversation surface: the slide-in history panel,
|
| 4 |
-
* the ephemeral on-orb bubbles, and all the transcript/tool/streaming
|
| 5 |
-
* bookkeeping. main.js wires the realtime client's events straight
|
| 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 |
|
|
@@ -216,6 +226,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 +251,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.
|
|
@@ -335,13 +368,8 @@ 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
|
|
@@ -374,6 +402,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
|
|
|
|
| 1 |
// @ts-check
|
| 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 and
|
| 5 |
+
* user-audio bookkeeping. main.js wires the realtime client's events straight
|
| 6 |
+
* 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 |
|
|
|
|
| 226 |
|
| 227 |
/** Reset the panel to the empty state and clear the unread badge. */
|
| 228 |
clear() {
|
| 229 |
+
this._stopUserAudioPlayback();
|
| 230 |
+
for (const url of this._audioUrls) URL.revokeObjectURL(url);
|
| 231 |
+
this._audioUrls.clear();
|
| 232 |
+
this._userAudioByItem.clear();
|
| 233 |
+
this._userHistByItem.clear();
|
| 234 |
this.renderEmptyState();
|
| 235 |
this._chatBadge.classList.remove("visible");
|
| 236 |
}
|
|
|
|
| 251 |
const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
|
| 252 |
if (!body) return;
|
| 253 |
body.textContent = text;
|
| 254 |
+
body.hidden = !text;
|
| 255 |
body.classList.toggle("partial", partial);
|
| 256 |
this._scrollToBottom();
|
| 257 |
}
|
| 258 |
|
| 259 |
+
/** @param {string} itemId */
|
| 260 |
+
_ensureUserHist(itemId) {
|
| 261 |
+
let hist = this._userHistByItem.get(itemId);
|
| 262 |
+
if (!hist) {
|
| 263 |
+
hist = this._appendHistMsg("user", "", false);
|
| 264 |
+
this._userHistByItem.set(itemId, hist);
|
| 265 |
+
}
|
| 266 |
+
return hist;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
_stopUserAudioPlayback() {
|
| 270 |
+
const active = this._activeUserAudio;
|
| 271 |
+
this._activeUserAudio = null;
|
| 272 |
+
if (active && !active.paused) active.pause();
|
| 273 |
+
this._onUserAudioPlaybackChange(false);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
/**
|
| 277 |
* Append a tool-call row to the conversation. We only add it once the tool
|
| 278 |
* has run, so the expandable toggle carries BOTH the call input and its result.
|
|
|
|
| 368 |
const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
|
| 369 |
const text = d.text;
|
| 370 |
|
| 371 |
+
const hist = this._ensureUserHist(id);
|
| 372 |
+
this._updateHistMsg(hist, text, d.partial);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
// One ephemeral bubble per active item. Purely timer-based: the timer is
|
| 375 |
// refreshed on every delta, so it stays while the user keeps talking and
|
|
|
|
| 402 |
}
|
| 403 |
}
|
| 404 |
|
| 405 |
+
/**
|
| 406 |
+
* Attach the browser-local recording to its user turn. This also creates an
|
| 407 |
+
* audio-only row when STT is disabled and no transcript events arrive.
|
| 408 |
+
* Reopened VAD segments reuse item_id; the client sends a replacement WAV
|
| 409 |
+
* containing the accumulated utterance, so the row keeps one player.
|
| 410 |
+
* @param {{ itemId?: string, audio: Blob, durationMs?: number, truncated?: boolean }} detail
|
| 411 |
+
*/
|
| 412 |
+
onUserAudio(detail) {
|
| 413 |
+
const id = detail.itemId || `_u${++this._anonSeq}`;
|
| 414 |
+
const hist = this._ensureUserHist(id);
|
| 415 |
+
let container = /** @type {HTMLElement | null} */ (hist.querySelector(".hist-audio"));
|
| 416 |
+
const isNewPlayer = !container;
|
| 417 |
+
if (!container) {
|
| 418 |
+
container = document.createElement("div");
|
| 419 |
+
container.className = "hist-audio";
|
| 420 |
+
container.innerHTML = `
|
| 421 |
+
<div class="hist-audio-label">Audio sent to the model</div>
|
| 422 |
+
<audio controls preload="metadata" aria-label="Replay your audio"></audio>
|
| 423 |
+
`;
|
| 424 |
+
hist.appendChild(container);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
const audio = /** @type {HTMLAudioElement} */ (container.querySelector("audio"));
|
| 428 |
+
const previous = this._userAudioByItem.get(id);
|
| 429 |
+
if (previous) {
|
| 430 |
+
if (this._activeUserAudio === previous.audio) this._stopUserAudioPlayback();
|
| 431 |
+
URL.revokeObjectURL(previous.url);
|
| 432 |
+
this._audioUrls.delete(previous.url);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
const url = URL.createObjectURL(detail.audio);
|
| 436 |
+
this._audioUrls.add(url);
|
| 437 |
+
this._userAudioByItem.set(id, { audio, url });
|
| 438 |
+
audio.src = url;
|
| 439 |
+
audio.title = detail.truncated
|
| 440 |
+
? "Replay your audio (the beginning was no longer buffered)"
|
| 441 |
+
: "Replay the audio sent to the model";
|
| 442 |
+
const label = container.querySelector(".hist-audio-label");
|
| 443 |
+
if (label) {
|
| 444 |
+
label.textContent = detail.truncated
|
| 445 |
+
? "Audio sent to the model · beginning unavailable"
|
| 446 |
+
: "Audio sent to the model";
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
if (isNewPlayer) {
|
| 450 |
+
audio.addEventListener("play", () => {
|
| 451 |
+
if (this._activeUserAudio && this._activeUserAudio !== audio) {
|
| 452 |
+
this._activeUserAudio.pause();
|
| 453 |
+
}
|
| 454 |
+
this._activeUserAudio = audio;
|
| 455 |
+
this._onUserAudioPlaybackChange(true);
|
| 456 |
+
});
|
| 457 |
+
const stopped = () => {
|
| 458 |
+
if (this._activeUserAudio !== audio) return;
|
| 459 |
+
this._activeUserAudio = null;
|
| 460 |
+
this._onUserAudioPlaybackChange(false);
|
| 461 |
+
};
|
| 462 |
+
audio.addEventListener("pause", stopped);
|
| 463 |
+
audio.addEventListener("ended", stopped);
|
| 464 |
+
audio.addEventListener("error", stopped);
|
| 465 |
+
}
|
| 466 |
+
this._scrollToBottom();
|
| 467 |
+
this._markUnread();
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
/**
|
| 471 |
* A response closed (completed or cancelled).
|
| 472 |
* @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail
|
ws/s2s-ws-client.js
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
| 93 |
trimTrailingSlash,
|
| 94 |
} from "./codec.js";
|
| 95 |
import { OrbVisualiser, VIS_FFT_SIZE } from "./orb-visualizer.js";
|
|
|
|
| 96 |
|
| 97 |
/** Build an Error carrying a `code` (and optional extra fields) so callers can
|
| 98 |
* branch on the failure kind: "limit" | "queue-full" | "queue-expired" | "aborted".
|
|
@@ -204,6 +205,9 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 204 |
this._sessionConfigured = false;
|
| 205 |
this._startupGreeting = options.startupGreeting?.trim() ?? "";
|
| 206 |
this._startupGreetingSent = false;
|
|
|
|
|
|
|
|
|
|
| 207 |
this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })();
|
| 208 |
}
|
| 209 |
|
|
@@ -619,6 +623,9 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 619 |
if (this._muted) return;
|
| 620 |
const b64 = base64FromArrayBuffer(pcm16Buffer);
|
| 621 |
this._send({ type: "input_audio_buffer.append", audio: b64 });
|
|
|
|
|
|
|
|
|
|
| 622 |
}
|
| 623 |
|
| 624 |
/**
|
|
@@ -683,10 +690,23 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 683 |
// otherwise keep playing over the user's barge-in.
|
| 684 |
this._playbackNode?.port.postMessage({ kind: "clear" });
|
| 685 |
this._aiSpeaking = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
this._setStatus("user-speaking");
|
| 687 |
break;
|
| 688 |
|
| 689 |
case "input_audio_buffer.speech_stopped":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
if (this._status === "user-speaking") this._setStatus("processing");
|
| 691 |
break;
|
| 692 |
|
|
@@ -1098,6 +1118,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 1098 |
// Abort a queue wait in progress: flag it and wake the poll sleep so
|
| 1099 |
// `_pollQueue` throws "aborted" and connect() unwinds cleanly.
|
| 1100 |
this._closed = true;
|
|
|
|
| 1101 |
if (this._queueWake) {
|
| 1102 |
clearTimeout(this._queueTimer);
|
| 1103 |
const wake = this._queueWake;
|
|
|
|
| 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".
|
|
|
|
| 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 |
|
|
|
|
| 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 |
/**
|
|
|
|
| 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._setStatus("user-speaking");
|
| 698 |
break;
|
| 699 |
|
| 700 |
case "input_audio_buffer.speech_stopped":
|
| 701 |
+
{
|
| 702 |
+
const recording = this._userAudioRecorder.speechStopped({
|
| 703 |
+
itemId: typeof event.item_id === "string" ? event.item_id : "",
|
| 704 |
+
audioEndMs: Number(event.audio_end_ms),
|
| 705 |
+
});
|
| 706 |
+
if (recording) {
|
| 707 |
+
this.dispatchEvent(new CustomEvent("user-audio", { detail: recording }));
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
if (this._status === "user-speaking") this._setStatus("processing");
|
| 711 |
break;
|
| 712 |
|
|
|
|
| 1118 |
// Abort a queue wait in progress: flag it and wake the poll sleep so
|
| 1119 |
// `_pollQueue` throws "aborted" and connect() unwinds cleanly.
|
| 1120 |
this._closed = true;
|
| 1121 |
+
this._userAudioRecorder.reset();
|
| 1122 |
if (this._queueWake) {
|
| 1123 |
clearTimeout(this._queueTimer);
|
| 1124 |
const wake = this._queueWake;
|
ws/user-audio-recorder.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); // PCM fmt chunk length
|
| 38 |
+
view.setUint16(20, 1, true); // linear PCM
|
| 39 |
+
view.setUint16(22, 1, true); // mono
|
| 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(0, Math.min(Math.round((Number(ms) * this.sampleRate) / 1000), this._sentSamples));
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/** @param {number} startSample @param {number} endSample */
|
| 165 |
+
_slice(startSample, endSample) {
|
| 166 |
+
/** @type {Uint8Array[]} */
|
| 167 |
+
const parts = [];
|
| 168 |
+
let length = 0;
|
| 169 |
+
for (const chunk of this._chunks) {
|
| 170 |
+
const overlapStart = Math.max(startSample, chunk.startSample);
|
| 171 |
+
const overlapEnd = Math.min(endSample, chunk.endSample);
|
| 172 |
+
if (overlapEnd <= overlapStart) continue;
|
| 173 |
+
const from = (overlapStart - chunk.startSample) * BYTES_PER_SAMPLE;
|
| 174 |
+
const to = (overlapEnd - chunk.startSample) * BYTES_PER_SAMPLE;
|
| 175 |
+
const part = chunk.bytes.slice(from, to);
|
| 176 |
+
parts.push(part);
|
| 177 |
+
length += part.byteLength;
|
| 178 |
+
}
|
| 179 |
+
const result = new Uint8Array(length);
|
| 180 |
+
let offset = 0;
|
| 181 |
+
for (const part of parts) {
|
| 182 |
+
result.set(part, offset);
|
| 183 |
+
offset += part.byteLength;
|
| 184 |
+
}
|
| 185 |
+
return result;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
_prune() {
|
| 189 |
+
const hardFloor = Math.max(0, this._sentSamples - this._maxBufferSamples);
|
| 190 |
+
const softFloor = this._active
|
| 191 |
+
? this._active.requestedStartSample
|
| 192 |
+
: Math.max(0, this._sentSamples - this._preRollSamples);
|
| 193 |
+
const floor = Math.max(hardFloor, softFloor);
|
| 194 |
+
while (this._chunks.length && this._chunks[0].endSample <= floor) {
|
| 195 |
+
this._chunks.shift();
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|