andito HF Staff commited on
Commit
a4795a6
·
1 Parent(s): 7b861ae

Add user audio replay

Browse files
Files changed (5) hide show
  1. main.js +22 -5
  2. style.css +23 -0
  3. ui/chat.js +105 -12
  4. ws/s2s-ws-client.js +21 -0
  5. 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
- const chat = new ChatView();
 
 
 
 
 
 
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
- for (const track of micStream.getAudioTracks()) {
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 to the
6
- * `on*` methods here and otherwise doesn't touch chat state.
7
  *
8
  * Two parallel surfaces share one shape (see `_buildMessageEl`):
9
  * - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer
@@ -24,7 +24,10 @@ const CHAT_BUBBLE_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="n
24
  const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_SVG}<span class="chat-empty-title">No messages yet</span><span class="chat-empty-hint">Tap the orb and start talking</span></div>`;
25
 
26
  export class ChatView {
27
- constructor() {
 
 
 
28
  /** @type {HTMLButtonElement} */
29
  this._chatBtn = $("#chat-btn");
30
  /** @type {HTMLSpanElement} */
@@ -46,6 +49,13 @@ export class ChatView {
46
  // ── User transcript state (keyed by item_id) ───────────────────────────
47
  /** @type {Map<string, HTMLElement>} */
48
  this._userHistByItem = new Map();
 
 
 
 
 
 
 
49
  /** @type {HTMLElement | null} */
50
  this._activeUserBubble = null;
51
  this._activeUserItemId = "";
@@ -121,7 +131,7 @@ export class ChatView {
121
  const el = document.createElement("div");
122
  el.className = `${container} ${role}`;
123
  const label = role === "user" ? "You" : "Assistant";
124
- el.innerHTML = `<div class="${prefix}-role">${label}</div><div class="${prefix}-body${partial ? " partial" : ""}">${escHtml(text)}</div>`;
125
  return el;
126
  }
127
 
@@ -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
- let hist = this._userHistByItem.get(id);
339
- if (!hist) {
340
- hist = this._appendHistMsg("user", text, d.partial);
341
- this._userHistByItem.set(id, hist);
342
- } else {
343
- this._updateHistMsg(hist, text, d.partial);
344
- }
345
 
346
  // One ephemeral bubble per active item. Purely timer-based: the timer is
347
  // refreshed on every delta, so it stays while the user keeps talking and
@@ -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
+ }