@kuralle-syrinx/browser-client 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/agent-state.d.ts +35 -0
  2. package/dist/agent-state.d.ts.map +1 -0
  3. package/dist/agent-state.js +76 -0
  4. package/dist/agent-state.js.map +1 -0
  5. package/dist/audio.d.ts +61 -0
  6. package/dist/audio.d.ts.map +1 -0
  7. package/dist/audio.js +276 -0
  8. package/dist/audio.js.map +1 -0
  9. package/dist/browser-opus.d.ts +41 -0
  10. package/dist/browser-opus.d.ts.map +1 -0
  11. package/dist/browser-opus.js +81 -0
  12. package/dist/browser-opus.js.map +1 -0
  13. package/dist/index.d.ts +270 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +744 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/session-metrics.d.ts +28 -0
  18. package/dist/session-metrics.d.ts.map +1 -0
  19. package/dist/session-metrics.js +60 -0
  20. package/dist/session-metrics.js.map +1 -0
  21. package/dist/session-record.d.ts +112 -0
  22. package/dist/session-record.d.ts.map +1 -0
  23. package/dist/session-record.js +181 -0
  24. package/dist/session-record.js.map +1 -0
  25. package/dist/transport.d.ts +16 -0
  26. package/dist/transport.d.ts.map +1 -0
  27. package/dist/transport.js +3 -0
  28. package/dist/transport.js.map +1 -0
  29. package/dist/turn-recorder.d.ts +56 -0
  30. package/dist/turn-recorder.d.ts.map +1 -0
  31. package/dist/turn-recorder.js +168 -0
  32. package/dist/turn-recorder.js.map +1 -0
  33. package/dist/turn-timeline.d.ts +51 -0
  34. package/dist/turn-timeline.d.ts.map +1 -0
  35. package/dist/turn-timeline.js +92 -0
  36. package/dist/turn-timeline.js.map +1 -0
  37. package/dist/websocket-transport.d.ts +20 -0
  38. package/dist/websocket-transport.d.ts.map +1 -0
  39. package/dist/websocket-transport.js +92 -0
  40. package/dist/websocket-transport.js.map +1 -0
  41. package/package.json +35 -11
  42. package/src/agent-state.test.ts +143 -0
  43. package/src/audio.test.ts +130 -0
  44. package/src/index.test.ts +842 -0
  45. package/src/jitter-buffer.test.ts +275 -0
  46. package/src/session-metrics.test.ts +102 -0
  47. package/src/session-record.test.ts +219 -0
  48. package/src/studio-page.test.ts +69 -0
  49. package/src/transport.test.ts +233 -0
  50. package/src/turn-recorder.test.ts +138 -0
  51. package/src/turn-timeline.test.ts +151 -0
  52. package/index.html +0 -1073
package/index.html DELETED
@@ -1,1073 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Syrinx University Review Studio</title>
7
- <style>
8
- :root {
9
- color-scheme: light;
10
- --paper: #f7f5ef;
11
- --panel: #fffdf8;
12
- --ink: #171714;
13
- --muted: #6c695f;
14
- --line: #d9d4c7;
15
- --line-strong: #aaa291;
16
- --green: #1f7a4d;
17
- --green-soft: #e6f1ea;
18
- --blue: #1e5b8f;
19
- --blue-soft: #e6eef6;
20
- --amber: #a05f00;
21
- --amber-soft: #f6ecd8;
22
- --red: #b13a2e;
23
- --red-soft: #f7e4e1;
24
- --shadow: 0 18px 50px rgba(31, 29, 24, 0.12);
25
- }
26
-
27
- * {
28
- box-sizing: border-box;
29
- }
30
-
31
- body {
32
- margin: 0;
33
- min-height: 100vh;
34
- background: var(--paper);
35
- color: var(--ink);
36
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
37
- }
38
-
39
- button,
40
- input,
41
- select {
42
- font: inherit;
43
- }
44
-
45
- button {
46
- border: 1px solid var(--ink);
47
- background: var(--ink);
48
- color: #fffdf8;
49
- border-radius: 7px;
50
- padding: 10px 13px;
51
- cursor: pointer;
52
- font-weight: 700;
53
- }
54
-
55
- button:hover:not(:disabled) {
56
- transform: translateY(-1px);
57
- }
58
-
59
- button:disabled {
60
- border-color: var(--line-strong);
61
- background: var(--line);
62
- color: var(--muted);
63
- cursor: not-allowed;
64
- }
65
-
66
- button.secondary {
67
- background: transparent;
68
- color: var(--ink);
69
- }
70
-
71
- button.recording {
72
- border-color: var(--red);
73
- background: var(--red);
74
- }
75
-
76
- input,
77
- select {
78
- width: 100%;
79
- border: 1px solid var(--line-strong);
80
- background: #fffefa;
81
- color: var(--ink);
82
- border-radius: 7px;
83
- padding: 9px 10px;
84
- }
85
-
86
- label {
87
- display: block;
88
- margin-bottom: 6px;
89
- color: var(--muted);
90
- font-size: 12px;
91
- font-weight: 800;
92
- letter-spacing: 0.05em;
93
- text-transform: uppercase;
94
- }
95
-
96
- .shell {
97
- display: grid;
98
- grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
99
- min-height: 100vh;
100
- }
101
-
102
- .rail {
103
- border-right: 1px solid var(--line);
104
- background: #ebe6d9;
105
- padding: 22px;
106
- overflow: auto;
107
- }
108
-
109
- .brand {
110
- display: flex;
111
- align-items: center;
112
- justify-content: space-between;
113
- gap: 16px;
114
- margin-bottom: 22px;
115
- }
116
-
117
- .brand h1 {
118
- margin: 0;
119
- font-size: 19px;
120
- line-height: 1.05;
121
- letter-spacing: 0;
122
- }
123
-
124
- .status {
125
- display: inline-flex;
126
- align-items: center;
127
- gap: 7px;
128
- border: 1px solid var(--line-strong);
129
- border-radius: 999px;
130
- padding: 6px 9px;
131
- background: var(--panel);
132
- color: var(--muted);
133
- font-size: 12px;
134
- font-weight: 800;
135
- }
136
-
137
- .dot {
138
- width: 8px;
139
- height: 8px;
140
- border-radius: 99px;
141
- background: var(--line-strong);
142
- }
143
-
144
- .status.connected .dot {
145
- background: var(--green);
146
- }
147
-
148
- .status.connecting .dot {
149
- background: var(--amber);
150
- }
151
-
152
- .section {
153
- border-top: 1px solid var(--line);
154
- padding: 18px 0;
155
- }
156
-
157
- .section:first-of-type {
158
- border-top: 0;
159
- padding-top: 0;
160
- }
161
-
162
- .stack {
163
- display: grid;
164
- gap: 10px;
165
- }
166
-
167
- .inline {
168
- display: grid;
169
- grid-template-columns: 1fr auto;
170
- gap: 8px;
171
- align-items: end;
172
- }
173
-
174
- .button-row {
175
- display: grid;
176
- grid-template-columns: 1fr 1fr;
177
- gap: 8px;
178
- }
179
-
180
- .meter {
181
- height: 58px;
182
- border: 1px solid var(--line-strong);
183
- border-radius: 7px;
184
- background: #fffefa;
185
- overflow: hidden;
186
- }
187
-
188
- .meter canvas {
189
- display: block;
190
- width: 100%;
191
- height: 100%;
192
- }
193
-
194
- .kv {
195
- display: grid;
196
- gap: 6px;
197
- }
198
-
199
- .kv div {
200
- display: flex;
201
- justify-content: space-between;
202
- gap: 16px;
203
- border-bottom: 1px solid rgba(170, 162, 145, 0.45);
204
- padding-bottom: 6px;
205
- color: var(--muted);
206
- font-size: 13px;
207
- }
208
-
209
- .kv strong {
210
- color: var(--ink);
211
- font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
212
- font-size: 12px;
213
- }
214
-
215
- .main {
216
- display: grid;
217
- grid-template-rows: auto minmax(0, 1fr);
218
- min-width: 0;
219
- }
220
-
221
- .topbar {
222
- display: flex;
223
- justify-content: space-between;
224
- align-items: center;
225
- gap: 18px;
226
- border-bottom: 1px solid var(--line);
227
- padding: 18px 24px;
228
- background: var(--panel);
229
- }
230
-
231
- .topbar h2 {
232
- margin: 0;
233
- font-size: 17px;
234
- }
235
-
236
- .topbar p {
237
- margin: 4px 0 0;
238
- color: var(--muted);
239
- font-size: 13px;
240
- }
241
-
242
- .content {
243
- display: grid;
244
- grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
245
- gap: 18px;
246
- padding: 18px;
247
- overflow: hidden;
248
- }
249
-
250
- .timeline,
251
- .sidefeed {
252
- min-height: 0;
253
- overflow: auto;
254
- }
255
-
256
- .timeline {
257
- display: grid;
258
- align-content: start;
259
- gap: 12px;
260
- }
261
-
262
- .turn {
263
- border: 1px solid var(--line);
264
- border-radius: 8px;
265
- background: var(--panel);
266
- box-shadow: var(--shadow);
267
- overflow: hidden;
268
- }
269
-
270
- .turn-head {
271
- display: flex;
272
- justify-content: space-between;
273
- align-items: center;
274
- gap: 10px;
275
- border-bottom: 1px solid var(--line);
276
- padding: 10px 12px;
277
- color: var(--muted);
278
- font-size: 12px;
279
- font-weight: 800;
280
- }
281
-
282
- .turn-body {
283
- display: grid;
284
- gap: 10px;
285
- padding: 12px;
286
- }
287
-
288
- .bubble {
289
- border-left: 4px solid var(--line-strong);
290
- border-radius: 5px;
291
- padding: 9px 10px;
292
- background: #fffefa;
293
- line-height: 1.45;
294
- }
295
-
296
- .bubble.user {
297
- border-color: var(--blue);
298
- background: var(--blue-soft);
299
- }
300
-
301
- .bubble.agent {
302
- border-color: var(--green);
303
- background: var(--green-soft);
304
- }
305
-
306
- .bubble.tool {
307
- border-color: var(--amber);
308
- background: var(--amber-soft);
309
- font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
310
- font-size: 12px;
311
- white-space: pre-wrap;
312
- }
313
-
314
- .event {
315
- border-bottom: 1px solid var(--line);
316
- padding: 10px 0;
317
- color: var(--muted);
318
- font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
319
- font-size: 12px;
320
- line-height: 1.45;
321
- }
322
-
323
- .event.error {
324
- color: var(--red);
325
- }
326
-
327
- .empty {
328
- border: 1px dashed var(--line-strong);
329
- border-radius: 8px;
330
- padding: 32px;
331
- color: var(--muted);
332
- background: rgba(255, 253, 248, 0.65);
333
- text-align: center;
334
- }
335
-
336
- @media (max-width: 920px) {
337
- .shell,
338
- .content {
339
- grid-template-columns: 1fr;
340
- }
341
-
342
- .rail {
343
- border-right: 0;
344
- border-bottom: 1px solid var(--line);
345
- }
346
-
347
- .content {
348
- overflow: visible;
349
- }
350
- }
351
- </style>
352
- </head>
353
- <body>
354
- <div class="shell">
355
- <aside class="rail">
356
- <div class="brand">
357
- <h1>Syrinx University Review</h1>
358
- <div id="status" class="status"><span class="dot"></span><span id="statusText">Offline</span></div>
359
- </div>
360
-
361
- <section class="section stack">
362
- <div>
363
- <label for="wsUrl">WebSocket</label>
364
- <input id="wsUrl" autocomplete="off" />
365
- </div>
366
- <div class="button-row">
367
- <button id="connectBtn">Connect</button>
368
- <button id="clearBtn" class="secondary">Clear</button>
369
- </div>
370
- </section>
371
-
372
- <section class="section stack">
373
- <div>
374
- <label for="micSelect">Microphone</label>
375
- <select id="micSelect"><option value="">Default input</option></select>
376
- </div>
377
- <button id="talkBtn" disabled>Start Listening</button>
378
- <div class="meter"><canvas id="meter"></canvas></div>
379
- </section>
380
-
381
- <section class="section stack">
382
- <div class="inline">
383
- <div>
384
- <label for="inputRate">Input PCM Hz</label>
385
- <input id="inputRate" value="16000" inputmode="numeric" />
386
- </div>
387
- <div>
388
- <label for="outputRate">Output PCM Hz</label>
389
- <input id="outputRate" value="16000" inputmode="numeric" />
390
- </div>
391
- </div>
392
- <div class="kv">
393
- <div><span>Profile</span><strong>interactive</strong></div>
394
- <div><span>STT</span><strong>Deepgram</strong></div>
395
- <div><span>LLM</span><strong>Gemini via AI SDK</strong></div>
396
- <div><span>TTS</span><strong>Cartesia or Gemini</strong></div>
397
- </div>
398
- </section>
399
-
400
- <section class="section">
401
- <div class="kv">
402
- <div><span>STT final</span><strong id="latStt">--</strong></div>
403
- <div><span>LLM first text</span><strong id="latLlm">--</strong></div>
404
- <div><span>TTS first audio</span><strong id="latTts">--</strong></div>
405
- <div><span>Speech to audio</span><strong id="latE2e">--</strong></div>
406
- </div>
407
- </section>
408
- </aside>
409
-
410
- <main class="main">
411
- <header class="topbar">
412
- <div>
413
- <h2>Live Student Relations Agent</h2>
414
- <p>Always-on listening streams PCM16 frames; VAD and endpointing decide turn boundaries.</p>
415
- </div>
416
- <button id="resumeAudioBtn" class="secondary">Enable Audio</button>
417
- </header>
418
- <div class="content">
419
- <section id="timeline" class="timeline">
420
- <div class="empty">Connect, start listening, then speak naturally.</div>
421
- </section>
422
- <aside id="events" class="sidefeed"></aside>
423
- </div>
424
- </main>
425
- </div>
426
-
427
- <script type="module">
428
- const wsUrl = document.getElementById("wsUrl");
429
- const status = document.getElementById("status");
430
- const statusText = document.getElementById("statusText");
431
- const connectBtn = document.getElementById("connectBtn");
432
- const clearBtn = document.getElementById("clearBtn");
433
- const talkBtn = document.getElementById("talkBtn");
434
- const resumeAudioBtn = document.getElementById("resumeAudioBtn");
435
- const micSelect = document.getElementById("micSelect");
436
- const inputRate = document.getElementById("inputRate");
437
- const outputRate = document.getElementById("outputRate");
438
- const timeline = document.getElementById("timeline");
439
- const events = document.getElementById("events");
440
- const meter = document.getElementById("meter");
441
- const meterCtx = meter.getContext("2d");
442
-
443
- const defaultWsProtocol = location.protocol === "https:" ? "wss:" : "ws:";
444
- wsUrl.value = location.host ? `${defaultWsProtocol}//${location.host}/ws` : "ws://127.0.0.1:4173/ws";
445
- const requestedWsUrl = new URLSearchParams(location.search).get("ws");
446
- if (requestedWsUrl) wsUrl.value = requestedWsUrl;
447
-
448
- let socket = null;
449
- let mediaStream = null;
450
- let audioContext = null;
451
- let sourceNode = null;
452
- let processorNode = null;
453
- let outputContext = null;
454
- let playbackAt = 0;
455
- const outputSources = new Set();
456
- const pendingAudioChunks = [];
457
- let recording = false;
458
- let activeTurn = null;
459
- let currentAssistantTurnId = null;
460
- let audioSequence = 0;
461
- let pcmQueue = new Int16Array(0);
462
- let preSpeechQueue = new Int16Array(0);
463
- let localSpeechActive = false;
464
- let meterPeak = 0;
465
- const turns = new Map();
466
- const startRmsThreshold = 0.012;
467
- const preSpeechMs = 400;
468
- window.__syrinxReviewState = {
469
- sentFrames: 0,
470
- sentEnvelopeFrames: 0,
471
- sentBytes: 0,
472
- contextIds: [],
473
- startedTurns: 0,
474
- audioContextSampleRateHz: 0,
475
- targetSampleRateHz: 0,
476
- receivedAssistantAudioFrames: 0,
477
- receivedAssistantEnvelopeFrames: 0,
478
- receivedAssistantBytes: 0,
479
- assistantSampleRateHz: 0,
480
- audioClearEvents: 0,
481
- localSpeechStartEvents: 0,
482
- clientInterruptsSent: 0,
483
- localSpeechStartFlushAtMs: 0,
484
- serverAudioClearAtMs: 0,
485
- audioPlaybackErrors: 0,
486
- metricsEvents: 0,
487
- lastMetrics: null,
488
- minPlaybackLeadMs: null,
489
- };
490
-
491
- function setStatus(kind, text) {
492
- status.className = `status ${kind}`;
493
- statusText.textContent = text;
494
- }
495
-
496
- function addEvent(text, kind = "") {
497
- const row = document.createElement("div");
498
- row.className = `event ${kind}`;
499
- row.textContent = `${new Date().toLocaleTimeString()} ${text}`;
500
- events.prepend(row);
501
- }
502
-
503
- function ensureTimelineReady() {
504
- const empty = timeline.querySelector(".empty");
505
- if (empty) empty.remove();
506
- }
507
-
508
- function ensureTurn(turnId) {
509
- if (turns.has(turnId)) return turns.get(turnId);
510
- ensureTimelineReady();
511
- const root = document.createElement("article");
512
- root.className = "turn";
513
- root.innerHTML = `
514
- <div class="turn-head"><span>${turnId}</span><span data-role="state">listening</span></div>
515
- <div class="turn-body">
516
- <div class="bubble user" data-role="user">Waiting for transcript...</div>
517
- <div class="bubble tool" data-role="tools">No tool calls yet.</div>
518
- <div class="bubble agent" data-role="agent">Waiting for assistant...</div>
519
- </div>
520
- `;
521
- timeline.prepend(root);
522
- const turn = {
523
- id: turnId,
524
- root,
525
- user: root.querySelector('[data-role="user"]'),
526
- tools: root.querySelector('[data-role="tools"]'),
527
- agent: root.querySelector('[data-role="agent"]'),
528
- state: root.querySelector('[data-role="state"]'),
529
- agentText: "",
530
- toolText: "",
531
- audioEndedAt: 0,
532
- sttFinalAt: 0,
533
- firstAgentAt: 0,
534
- firstAudioAt: 0,
535
- };
536
- turns.set(turnId, turn);
537
- return turn;
538
- }
539
-
540
- function updateLatency(turn) {
541
- if (turn.audioEndedAt && turn.sttFinalAt) {
542
- document.getElementById("latStt").textContent = `${turn.sttFinalAt - turn.audioEndedAt}ms`;
543
- }
544
- if (turn.sttFinalAt && turn.firstAgentAt) {
545
- document.getElementById("latLlm").textContent = `${turn.firstAgentAt - turn.sttFinalAt}ms`;
546
- }
547
- if (turn.firstAgentAt && turn.firstAudioAt) {
548
- document.getElementById("latTts").textContent = `${turn.firstAudioAt - turn.firstAgentAt}ms`;
549
- }
550
- if (turn.audioEndedAt && turn.firstAudioAt) {
551
- document.getElementById("latE2e").textContent = `${turn.firstAudioAt - turn.audioEndedAt}ms`;
552
- }
553
- }
554
-
555
- connectBtn.addEventListener("click", () => {
556
- if (socket && socket.readyState === WebSocket.OPEN) {
557
- socket.close();
558
- return;
559
- }
560
- connect();
561
- });
562
-
563
- clearBtn.addEventListener("click", () => {
564
- turns.clear();
565
- timeline.innerHTML = '<div class="empty">Connect, start listening, then speak naturally.</div>';
566
- events.innerHTML = "";
567
- });
568
-
569
- talkBtn.addEventListener("click", async () => {
570
- if (recording) {
571
- await stopRecording();
572
- return;
573
- }
574
- await startRecording();
575
- });
576
- talkBtn.addEventListener("keydown", async (event) => {
577
- if (event.code === "Space" || event.code === "Enter") {
578
- event.preventDefault();
579
- if (recording) {
580
- await stopRecording();
581
- return;
582
- }
583
- await startRecording();
584
- }
585
- });
586
-
587
- resumeAudioBtn.addEventListener("click", async () => {
588
- await ensureOutputContext();
589
- addEvent("audio output enabled");
590
- });
591
-
592
- function connect() {
593
- setStatus("connecting", "Connecting");
594
- connectBtn.disabled = true;
595
- socket = new WebSocket(wsUrl.value);
596
- socket.binaryType = "arraybuffer";
597
- socket.addEventListener("open", () => {
598
- setStatus("connected", "Online");
599
- connectBtn.disabled = false;
600
- connectBtn.textContent = "Disconnect";
601
- talkBtn.disabled = false;
602
- // This page decodes PCM16 downlink only (no Opus decoder), so declare it —
603
- // otherwise the server streams Opus envelopes this decoder rejects.
604
- socket.send(JSON.stringify({ type: "codec_capability", downlinkEncoding: "pcm_s16le" }));
605
- addEvent(`connected ${wsUrl.value}`);
606
- });
607
- socket.addEventListener("close", () => {
608
- setStatus("", "Offline");
609
- connectBtn.disabled = false;
610
- connectBtn.textContent = "Connect";
611
- talkBtn.disabled = true;
612
- addEvent("socket closed");
613
- });
614
- socket.addEventListener("error", () => {
615
- addEvent("websocket error", "error");
616
- });
617
- socket.addEventListener("message", (event) => {
618
- if (typeof event.data === "string") {
619
- handleJson(JSON.parse(event.data));
620
- return;
621
- }
622
- void handleAudio(event.data);
623
- });
624
- }
625
-
626
- function handleJson(message) {
627
- if (message.type === "ready") {
628
- addEvent(`session ready ${message.sessionId || ""}`.trim());
629
- if (message.audio?.inputSampleRateHz) inputRate.value = String(message.audio.inputSampleRateHz);
630
- if (message.audio?.outputSampleRateHz) outputRate.value = String(message.audio.outputSampleRateHz);
631
- return;
632
- }
633
- if (message.type === "error") {
634
- addEvent(`${message.component || "error"}: ${message.message}`, "error");
635
- return;
636
- }
637
- const turnId = message.turnId || activeTurn?.id || "unknown-turn";
638
- const turn = ensureTurn(turnId);
639
- if (message.type === "speech_started") {
640
- turn.audioEndedAt = 0;
641
- turn.state.textContent = "listening";
642
- } else if (message.type === "speech_ended") {
643
- if (activeTurn?.id === turn.id) {
644
- drainPcmQueue(turn.id);
645
- }
646
- if (!turn.audioEndedAt) turn.audioEndedAt = Date.now();
647
- turn.state.textContent = "finalizing";
648
- updateLatency(turn);
649
- } else if (message.type === "audio_clear") {
650
- window.__syrinxReviewState.audioClearEvents += 1;
651
- if (!window.__syrinxReviewState.serverAudioClearAtMs) {
652
- window.__syrinxReviewState.serverAudioClearAtMs = performance.now();
653
- }
654
- flushOutputAudio();
655
- turn.state.textContent = "interrupted";
656
- addEvent(`cleared assistant audio for ${turnId}`);
657
- } else if (message.type === "agent_interrupted") {
658
- turn.state.textContent = "interrupted";
659
- } else if (message.type === "stt_chunk") {
660
- turn.user.textContent = message.transcript || "";
661
- turn.state.textContent = "transcribing";
662
- } else if (message.type === "stt_output") {
663
- turn.sttFinalAt = Date.now();
664
- turn.user.textContent = message.transcript || "";
665
- turn.state.textContent = "agent";
666
- updateLatency(turn);
667
- } else if (message.type === "turn_complete") {
668
- if (activeTurn?.id === turn.id) activeTurn = null;
669
- if (message.transcript) turn.user.textContent = message.transcript;
670
- turn.state.textContent = "agent";
671
- } else if (message.type === "agent_tool_call") {
672
- turn.toolText += `${message.name || "tool"} ${JSON.stringify(message.args || {})}\n`;
673
- turn.tools.textContent = turn.toolText.trim();
674
- } else if (message.type === "agent_tool_result") {
675
- turn.toolText += `result ${message.id || ""}\n`;
676
- turn.tools.textContent = turn.toolText.trim();
677
- } else if (message.type === "agent_chunk") {
678
- currentAssistantTurnId = turnId;
679
- if (!turn.firstAgentAt) turn.firstAgentAt = Date.now();
680
- turn.agentText += message.text || "";
681
- turn.agent.textContent = turn.agentText;
682
- turn.state.textContent = "speaking";
683
- updateLatency(turn);
684
- } else if (message.type === "agent_end") {
685
- turn.state.textContent = "tts";
686
- } else if (message.type === "tts_chunk") {
687
- currentAssistantTurnId = turnId;
688
- pendingAudioChunks.push(message);
689
- } else if (message.type === "tts_end") {
690
- turn.state.textContent = "done";
691
- updateLatency(turn);
692
- if (recording && activeTurn?.id === turn.id) activeTurn = null;
693
- } else if (message.type === "metrics") {
694
- window.__syrinxReviewState.metricsEvents += 1;
695
- window.__syrinxReviewState.lastMetrics = message;
696
- }
697
- }
698
-
699
- async function startRecording() {
700
- if (recording || !socket || socket.readyState !== WebSocket.OPEN) return;
701
- await ensureOutputContext();
702
- flushOutputAudio();
703
- const targetRate = readSampleRate(inputRate.value, 16000);
704
- activeTurn = null;
705
- pcmQueue = new Int16Array(0);
706
- preSpeechQueue = new Int16Array(0);
707
- localSpeechActive = false;
708
- mediaStream = await navigator.mediaDevices.getUserMedia({
709
- audio: {
710
- deviceId: micSelect.value ? { exact: micSelect.value } : undefined,
711
- channelCount: 1,
712
- echoCancellation: true,
713
- noiseSuppression: true,
714
- autoGainControl: true,
715
- },
716
- });
717
- audioContext = new AudioContext();
718
- window.__syrinxReviewState.audioContextSampleRateHz = audioContext.sampleRate;
719
- window.__syrinxReviewState.targetSampleRateHz = targetRate;
720
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
721
- processorNode = audioContext.createScriptProcessor(2048, 1, 1);
722
- processorNode.onaudioprocess = (audioEvent) => {
723
- const input = audioEvent.inputBuffer.getChannelData(0);
724
- const inputRms = rms(input);
725
- meterPeak = Math.max(meterPeak * 0.82, inputRms);
726
- const resampled = resampleFloat32(input, audioContext.sampleRate, targetRate);
727
- const pcm = floatToPcm16(resampled);
728
- const speechNow = inputRms >= startRmsThreshold;
729
- if (speechNow && !localSpeechActive) {
730
- localSpeechActive = true;
731
- handleLocalSpeechStart("local_vad_speech_start");
732
- } else if (!speechNow) {
733
- localSpeechActive = false;
734
- }
735
- if (!activeTurn) {
736
- appendPreSpeech(pcm, targetRate);
737
- if (!speechNow) return;
738
- activeTurn = createCaptureTurn();
739
- activeTurn.state.textContent = "listening";
740
- queuePcm(preSpeechQueue, targetRate, activeTurn.id);
741
- preSpeechQueue = new Int16Array(0);
742
- return;
743
- }
744
- queuePcm(pcm, targetRate, activeTurn.id);
745
- };
746
- sourceNode.connect(processorNode);
747
- processorNode.connect(audioContext.destination);
748
- recording = true;
749
- talkBtn.classList.add("recording");
750
- talkBtn.textContent = "Stop Listening";
751
- addEvent("microphone listening");
752
- }
753
-
754
- async function stopRecording() {
755
- if (!recording) return;
756
- recording = false;
757
- processorNode?.disconnect();
758
- sourceNode?.disconnect();
759
- mediaStream?.getTracks().forEach((track) => track.stop());
760
- await audioContext?.close();
761
- processorNode = null;
762
- sourceNode = null;
763
- mediaStream = null;
764
- audioContext = null;
765
- preSpeechQueue = new Int16Array(0);
766
- localSpeechActive = false;
767
- if (activeTurn) {
768
- drainPcmQueue(activeTurn.id);
769
- activeTurn.audioEndedAt = Date.now();
770
- sendSilence(activeTurn.id, readSampleRate(inputRate.value, 16000), 1200);
771
- activeTurn.state.textContent = "finalizing";
772
- updateLatency(activeTurn);
773
- }
774
- talkBtn.classList.remove("recording");
775
- talkBtn.textContent = "Start Listening";
776
- }
777
-
778
- function handleLocalSpeechStart(reason = "local_vad_speech_start") {
779
- if (!isAssistantPlayoutActive()) return;
780
- window.__syrinxReviewState.localSpeechStartEvents += 1;
781
- flushOutputAudio();
782
- if (!window.__syrinxReviewState.localSpeechStartFlushAtMs) {
783
- window.__syrinxReviewState.localSpeechStartFlushAtMs = performance.now();
784
- }
785
- sendClientInterrupt(reason);
786
- }
787
-
788
- function isAssistantPlayoutActive() {
789
- return outputSources.size > 0 || pendingAudioChunks.length > 0;
790
- }
791
-
792
- function sendClientInterrupt(reason) {
793
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
794
- window.__syrinxReviewState.clientInterruptsSent += 1;
795
- socket.send(JSON.stringify({
796
- type: "client_interrupt",
797
- assistantContextId: currentAssistantTurnId || undefined,
798
- reason,
799
- }));
800
- }
801
-
802
- function createCaptureTurn() {
803
- window.__syrinxReviewState.startedTurns += 1;
804
- return ensureTurn(`review-${Date.now().toString(36)}`);
805
- }
806
-
807
- function appendPreSpeech(samples, sampleRate) {
808
- const next = new Int16Array(preSpeechQueue.length + samples.length);
809
- next.set(preSpeechQueue);
810
- next.set(samples, preSpeechQueue.length);
811
- const maxSamples = Math.round(sampleRate * preSpeechMs / 1000);
812
- preSpeechQueue = next.length > maxSamples ? next.slice(next.length - maxSamples) : next;
813
- }
814
-
815
- function queuePcm(samples, sampleRate, turnId) {
816
- const next = new Int16Array(pcmQueue.length + samples.length);
817
- next.set(pcmQueue);
818
- next.set(samples, pcmQueue.length);
819
- pcmQueue = next;
820
- const frameSamples = Math.round(sampleRate * 0.02);
821
- while (pcmQueue.length >= frameSamples) {
822
- sendAudioFrame(turnId, pcmQueue.subarray(0, frameSamples));
823
- pcmQueue = pcmQueue.slice(frameSamples);
824
- }
825
- }
826
-
827
- function drainPcmQueue(turnId) {
828
- if (pcmQueue.length === 0) return;
829
- sendAudioFrame(turnId, pcmQueue);
830
- pcmQueue = new Int16Array(0);
831
- }
832
-
833
- function sendSilence(turnId, sampleRate, durationMs) {
834
- const frameSamples = Math.round(sampleRate * 0.02);
835
- const frames = Math.ceil(durationMs / 20);
836
- const silence = new Int16Array(frameSamples);
837
- for (let i = 0; i < frames; i += 1) sendAudioFrame(turnId, silence);
838
- }
839
-
840
- function sendAudioFrame(turnId, frame) {
841
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
842
- const byteLength = frame.byteLength;
843
- window.__syrinxReviewState.sentFrames += 1;
844
- window.__syrinxReviewState.sentEnvelopeFrames += 1;
845
- window.__syrinxReviewState.sentBytes += byteLength;
846
- if (!window.__syrinxReviewState.contextIds.includes(turnId)) {
847
- window.__syrinxReviewState.contextIds.push(turnId);
848
- }
849
- socket.send(encodeAudioEnvelope({
850
- type: "audio",
851
- contextId: turnId,
852
- sampleRateHz: readSampleRate(inputRate.value, 16000),
853
- sequence: ++audioSequence,
854
- encoding: "pcm_s16le",
855
- channels: 1,
856
- byteLength,
857
- durationMs: Math.round((frame.length / readSampleRate(inputRate.value, 16000)) * 1000),
858
- }, new Uint8Array(frame.buffer, frame.byteOffset, frame.byteLength)));
859
- }
860
-
861
- function encodeAudioEnvelope(metadata, audioBytes) {
862
- const magic = new Uint8Array([83, 89, 82, 88, 65, 49, 10]);
863
- const header = new TextEncoder().encode(JSON.stringify(metadata));
864
- const output = new Uint8Array(magic.byteLength + 4 + header.byteLength + audioBytes.byteLength);
865
- output.set(magic, 0);
866
- new DataView(output.buffer).setUint32(magic.byteLength, header.byteLength, true);
867
- output.set(header, magic.byteLength + 4);
868
- output.set(audioBytes, magic.byteLength + 4 + header.byteLength);
869
- return output;
870
- }
871
-
872
- async function handleAudio(data) {
873
- try {
874
- const arrayBuffer = data instanceof Blob ? await data.arrayBuffer() : data;
875
- const decoded = decodeAssistantAudio(arrayBuffer);
876
- const chunk = pendingAudioChunks.shift();
877
- const sampleRate = decoded.metadata?.sampleRateHz || chunk?.sampleRateHz || readSampleRate(outputRate.value, 16000);
878
- const turnId = decoded.metadata?.contextId || chunk?.turnId;
879
- if (turnId) currentAssistantTurnId = turnId;
880
- const turn = turnId ? ensureTurn(turnId) : activeTurn;
881
- window.__syrinxReviewState.receivedAssistantAudioFrames += 1;
882
- window.__syrinxReviewState.receivedAssistantBytes += decoded.audio.byteLength;
883
- window.__syrinxReviewState.assistantSampleRateHz = sampleRate;
884
- if (decoded.metadata) window.__syrinxReviewState.receivedAssistantEnvelopeFrames += 1;
885
- if (turn && !turn.firstAudioAt) {
886
- turn.firstAudioAt = Date.now();
887
- updateLatency(turn);
888
- }
889
- await playPcm16(decoded.audio, sampleRate);
890
- } catch (err) {
891
- window.__syrinxReviewState.audioPlaybackErrors += 1;
892
- addEvent(`assistant audio failed: ${err instanceof Error ? err.message : String(err)}`, "error");
893
- }
894
- }
895
-
896
- function decodeAssistantAudio(arrayBuffer) {
897
- const bytes = new Uint8Array(arrayBuffer);
898
- const magic = [83, 89, 82, 88, 65, 49, 10];
899
- if (!hasPrefix(bytes, magic)) return { audio: arrayBuffer, metadata: null };
900
- if (bytes.byteLength < magic.length + 4) throw new Error("truncated Syrinx audio envelope");
901
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
902
- const headerLength = view.getUint32(magic.length, true);
903
- const headerStart = magic.length + 4;
904
- const headerEnd = headerStart + headerLength;
905
- if (headerLength <= 0 || headerEnd > bytes.byteLength) throw new Error("invalid Syrinx audio envelope");
906
- const metadata = JSON.parse(new TextDecoder().decode(bytes.subarray(headerStart, headerEnd)));
907
- const audioBytes = bytes.subarray(headerEnd);
908
- if (metadata.type !== "audio") throw new Error("Syrinx audio envelope type must be audio");
909
- if (!Number.isInteger(metadata.sampleRateHz) || metadata.sampleRateHz <= 0) {
910
- throw new Error("Syrinx audio envelope sampleRateHz must be a positive integer");
911
- }
912
- // This page decodes pcm_s16le only (no Opus decoder). Reject other encodings with a
913
- // clear message instead of letting the PCM16 invariants below fail cryptically — the
914
- // pcm_s16le codec_capability we send on connect should prevent this from happening.
915
- if (metadata.encoding && metadata.encoding !== "pcm_s16le") {
916
- throw new Error(`Syrinx studio decodes pcm_s16le only; server sent ${metadata.encoding} downlink (codec negotiation failed)`);
917
- }
918
- if (audioBytes.byteLength % 2 !== 0) {
919
- throw new Error("Syrinx audio envelope PCM16 payload must contain an even number of bytes");
920
- }
921
- if (typeof metadata.byteLength === "number" && metadata.byteLength !== audioBytes.byteLength) {
922
- throw new Error("Syrinx audio envelope byteLength mismatch");
923
- }
924
- if (typeof metadata.durationMs === "number") {
925
- const expectedDurationMs = Math.round((audioBytes.byteLength / 2 / metadata.sampleRateHz) * 1000);
926
- if (Math.abs(metadata.durationMs - expectedDurationMs) > 1) {
927
- throw new Error("Syrinx audio envelope durationMs mismatch");
928
- }
929
- }
930
- return {
931
- audio: audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength),
932
- metadata,
933
- };
934
- }
935
-
936
- function hasPrefix(bytes, prefix) {
937
- if (bytes.byteLength < prefix.length) return false;
938
- for (let i = 0; i < prefix.length; i += 1) {
939
- if (bytes[i] !== prefix[i]) return false;
940
- }
941
- return true;
942
- }
943
-
944
- async function ensureOutputContext() {
945
- if (!outputContext || outputContext.state === "closed") {
946
- outputContext = new AudioContext();
947
- playbackAt = outputContext.currentTime;
948
- }
949
- if (outputContext.state === "suspended") await outputContext.resume();
950
- }
951
-
952
- async function playPcm16(arrayBuffer, sampleRate) {
953
- await ensureOutputContext();
954
- const bytes = new Uint8Array(arrayBuffer);
955
- const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
956
- const buffer = outputContext.createBuffer(1, samples.length, sampleRate);
957
- const channel = buffer.getChannelData(0);
958
- for (let i = 0; i < samples.length; i += 1) channel[i] = samples[i] / 32768;
959
- const source = outputContext.createBufferSource();
960
- source.buffer = buffer;
961
- source.connect(outputContext.destination);
962
- source.addEventListener("ended", () => {
963
- outputSources.delete(source);
964
- });
965
- outputSources.add(source);
966
- playbackAt = Math.max(playbackAt, outputContext.currentTime + 0.02);
967
- const leadMs = (playbackAt - outputContext.currentTime) * 1000;
968
- window.__syrinxReviewState.minPlaybackLeadMs = Math.min(
969
- window.__syrinxReviewState.minPlaybackLeadMs ?? leadMs,
970
- leadMs,
971
- );
972
- source.start(playbackAt);
973
- playbackAt += buffer.duration;
974
- }
975
-
976
- function flushOutputAudio() {
977
- for (const source of outputSources) {
978
- try {
979
- source.stop();
980
- } catch {
981
- // Already stopped.
982
- }
983
- }
984
- outputSources.clear();
985
- pendingAudioChunks.length = 0;
986
- if (outputContext) playbackAt = outputContext.currentTime;
987
- }
988
-
989
- window.__syrinxReviewTriggerLocalSpeechStart = () => {
990
- handleLocalSpeechStart("local_vad_speech_start");
991
- };
992
-
993
- window.__syrinxReviewInjectPcmFrame = () => {
994
- if (!activeTurn) {
995
- activeTurn = createCaptureTurn();
996
- activeTurn.state.textContent = "listening";
997
- }
998
- const frame = new Int16Array(Math.round(readSampleRate(inputRate.value, 16000) * 0.02));
999
- frame.fill(1200);
1000
- sendAudioFrame(activeTurn.id, frame);
1001
- };
1002
-
1003
- function floatToPcm16(samples) {
1004
- const pcm = new Int16Array(samples.length);
1005
- for (let i = 0; i < samples.length; i += 1) {
1006
- const clamped = Math.max(-1, Math.min(1, samples[i]));
1007
- pcm[i] = clamped < 0 ? clamped * 32768 : clamped * 32767;
1008
- }
1009
- return pcm;
1010
- }
1011
-
1012
- function resampleFloat32(input, fromRate, toRate) {
1013
- if (fromRate === toRate) return input;
1014
- const outLength = Math.max(1, Math.round((input.length * toRate) / fromRate));
1015
- const output = new Float32Array(outLength);
1016
- const ratio = fromRate / toRate;
1017
- for (let i = 0; i < outLength; i += 1) {
1018
- const src = i * ratio;
1019
- const lo = Math.floor(src);
1020
- const hi = Math.min(input.length - 1, lo + 1);
1021
- const frac = src - lo;
1022
- output[i] = input[lo] * (1 - frac) + input[hi] * frac;
1023
- }
1024
- return output;
1025
- }
1026
-
1027
- function readSampleRate(value, fallback) {
1028
- const parsed = Number.parseInt(value, 10);
1029
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1030
- }
1031
-
1032
- function rms(samples) {
1033
- let sum = 0;
1034
- for (let i = 0; i < samples.length; i += 1) sum += samples[i] * samples[i];
1035
- return Math.sqrt(sum / Math.max(1, samples.length));
1036
- }
1037
-
1038
- function drawMeter() {
1039
- const width = meter.clientWidth * window.devicePixelRatio;
1040
- const height = meter.clientHeight * window.devicePixelRatio;
1041
- if (meter.width !== width || meter.height !== height) {
1042
- meter.width = width;
1043
- meter.height = height;
1044
- }
1045
- meterCtx.clearRect(0, 0, width, height);
1046
- meterCtx.fillStyle = "#fffefa";
1047
- meterCtx.fillRect(0, 0, width, height);
1048
- meterCtx.fillStyle = recording ? "#b13a2e" : "#1f7a4d";
1049
- meterCtx.fillRect(0, 0, Math.min(width, width * meterPeak * 5), height);
1050
- meterCtx.strokeStyle = "#aaa291";
1051
- meterCtx.strokeRect(0.5, 0.5, width - 1, height - 1);
1052
- meterPeak *= 0.94;
1053
- requestAnimationFrame(drawMeter);
1054
- }
1055
-
1056
- async function populateMics() {
1057
- if (!navigator.mediaDevices?.enumerateDevices) return;
1058
- const devices = await navigator.mediaDevices.enumerateDevices();
1059
- const inputs = devices.filter((device) => device.kind === "audioinput");
1060
- micSelect.innerHTML = '<option value="">Default input</option>';
1061
- for (const device of inputs) {
1062
- const option = document.createElement("option");
1063
- option.value = device.deviceId;
1064
- option.textContent = device.label || `Input ${micSelect.length}`;
1065
- micSelect.append(option);
1066
- }
1067
- }
1068
-
1069
- void populateMics();
1070
- requestAnimationFrame(drawMeter);
1071
- </script>
1072
- </body>
1073
- </html>