@kuralle-syrinx/browser-client 2.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,864 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export {
4
+ decodeBrowserAssistantAudio,
5
+ encodeBrowserAudioEnvelopeFrame,
6
+ encodeBrowserAudioFrame,
7
+ float32ToPcm16,
8
+ pcm16FrameSampleCount,
9
+ resampleFloat32Linear,
10
+ type EncodeBrowserAudioOptions,
11
+ type ResampleFloat32Options,
12
+ type SyrinxAudioJsonFrame,
13
+ } from "./audio.js";
14
+
15
+ import {
16
+ createBrowserOpusCodec,
17
+ encodeBrowserOpusEnvelope,
18
+ encodeBrowserPcmEnvelope,
19
+ loadBrowserOpusModule,
20
+ pickBrowserWireCodec,
21
+ type BrowserOpusCodec,
22
+ type BrowserWireCodec,
23
+ } from "./browser-opus.js";
24
+ import { BROWSER_OPUS_SAMPLE_RATE_HZ } from "./browser-opus.js";
25
+ import {
26
+ pcm16BytesToSamples,
27
+ pcm16SamplesToBytes,
28
+ resamplePcm16Streaming,
29
+ type StreamingPcm16Resampler,
30
+ } from "@kuralle-syrinx/core/audio";
31
+ import {
32
+ decodeBrowserAssistantAudio,
33
+ encodeBrowserAudioEnvelopeFrame,
34
+ float32ToPcm16,
35
+ resampleFloat32Linear,
36
+ AudioJitterBuffer,
37
+ type BrowserAssistantAudio,
38
+ type EncodeBrowserAudioOptions,
39
+ type AudioJitterBufferOptions,
40
+ } from "./audio.js";
41
+ import type { ClientTransport } from "./transport.js";
42
+ import { WebSocketClientTransport } from "./websocket-transport.js";
43
+
44
+ export type { ClientTransport, ClientTransportHandlers } from "./transport.js";
45
+ export { WebSocketClientTransport } from "./websocket-transport.js";
46
+ export {
47
+ BROWSER_OPUS_FRAME_DURATION_MS,
48
+ BROWSER_OPUS_SAMPLE_RATE_HZ,
49
+ BROWSER_SUPPORTED_INPUT_CODECS,
50
+ createBrowserOpusCodec,
51
+ encodeBrowserOpusEnvelope,
52
+ encodeBrowserPcmEnvelope,
53
+ loadBrowserOpusModule,
54
+ pickBrowserWireCodec,
55
+ type BrowserOpusCodec,
56
+ type BrowserWireCodec,
57
+ } from "./browser-opus.js";
58
+
59
+ export type SyrinxStudioMessage =
60
+ | {
61
+ readonly type: "ready";
62
+ readonly sessionId?: string;
63
+ readonly resumed?: boolean;
64
+ readonly resumeWindowMs?: number;
65
+ readonly audio?: {
66
+ readonly inputSampleRateHz: number;
67
+ readonly outputSampleRateHz: number;
68
+ readonly encoding: "pcm_s16le" | "opus";
69
+ readonly supportedInputCodecs?: readonly string[];
70
+ readonly channels: 1;
71
+ readonly binaryEnvelope?: "syrinx.audio.v1";
72
+ readonly rawBinaryInput?: boolean;
73
+ };
74
+ }
75
+ | { readonly type: "speech_started"; readonly turnId?: string }
76
+ | { readonly type: "speech_ended"; readonly turnId?: string }
77
+ | { readonly type: "stt_chunk"; readonly turnId?: string; readonly transcript: string }
78
+ | { readonly type: "stt_output"; readonly turnId?: string; readonly transcript: string; readonly confidence?: number }
79
+ | { readonly type: "agent_chunk"; readonly turnId?: string; readonly text: string }
80
+ | { readonly type: "agent_tool_call"; readonly turnId?: string; readonly id?: string; readonly name: string; readonly args?: unknown }
81
+ | { readonly type: "agent_tool_result"; readonly turnId?: string; readonly id?: string; readonly result?: unknown }
82
+ | { readonly type: "agent_end"; readonly turnId?: string }
83
+ | { readonly type: "agent_interrupted"; readonly turnId?: string; readonly reason?: string }
84
+ | { readonly type: "audio_clear"; readonly turnId?: string; readonly reason?: string }
85
+ | { readonly type: "tts_end"; readonly turnId?: string }
86
+ | { readonly type: "turn_complete"; readonly turnId?: string; readonly transcript: string }
87
+ | {
88
+ readonly type: "tts_chunk";
89
+ readonly turnId?: string;
90
+ readonly sequence: number;
91
+ readonly sampleRateHz: number;
92
+ readonly encoding: "pcm_s16le";
93
+ readonly channels: 1;
94
+ readonly byteLength: number;
95
+ readonly durationMs: number;
96
+ }
97
+ | {
98
+ readonly type: "metrics";
99
+ readonly turnId?: string;
100
+ readonly correlationId?: string;
101
+ readonly speechEndMs?: number;
102
+ readonly textReadyMs?: number;
103
+ readonly firstAudioByteMs?: number;
104
+ readonly firstAudioPlayedMs?: number;
105
+ readonly lastAudioPlayedMs?: number;
106
+ readonly sttMs?: number;
107
+ readonly llmTTFTMs?: number;
108
+ readonly ttsTTFBMs?: number;
109
+ readonly e2eMs?: number;
110
+ }
111
+ | { readonly type: "error"; readonly component?: string; readonly category?: string; readonly message: string };
112
+
113
+ type SyrinxReadyAudio = Extract<SyrinxStudioMessage, { readonly type: "ready" }>["audio"];
114
+
115
+ export type SyrinxBrowserClientEvent =
116
+ | { readonly type: "open" }
117
+ | { readonly type: "close"; readonly code: number; readonly reason: string }
118
+ | { readonly type: "error"; readonly error: Event | Error }
119
+ | { readonly type: "message"; readonly message: SyrinxStudioMessage }
120
+ | { readonly type: "audio"; readonly data: ArrayBuffer; readonly metadata?: BrowserAssistantAudio["metadata"] }
121
+ | { readonly type: "reconnecting"; readonly attempt: number; readonly delayMs: number }
122
+ | { readonly type: "reconnected"; readonly attempt: number }
123
+ | { readonly type: "resumed" };
124
+
125
+ export type SyrinxBrowserClientHandler = (event: SyrinxBrowserClientEvent) => void;
126
+
127
+ export interface SyrinxBrowserClientOptions {
128
+ readonly url: string;
129
+ readonly protocols?: string | readonly string[];
130
+ /** Injectable transport seam (default: WebSocket). Future: WebRTC / WebTransport. */
131
+ readonly transport?: ClientTransport;
132
+ /**
133
+ * Auto-reconnect on unexpected close. Set false to disable entirely.
134
+ * Defaults to enabled with 10 attempts, 1 s base delay, 30 s cap.
135
+ */
136
+ readonly reconnect?: false | {
137
+ readonly maxAttempts?: number;
138
+ readonly baseDelayMs?: number;
139
+ readonly maxDelayMs?: number;
140
+ /** A reconnect that opens then dies within this window counts as a quick failure. Default 5000 ms. */
141
+ readonly minStableMs?: number;
142
+ /** Consecutive quick failures (open-then-die) before giving up — backoff can't fix a flapping peer. Default 3. */
143
+ readonly maxQuickFailures?: number;
144
+ };
145
+ /**
146
+ * Interval (ms) for periodic {type:"ping"} keepalives. Set false to disable.
147
+ * Default: 10 000 ms — below typical proxy idle-kill thresholds.
148
+ */
149
+ readonly keepaliveIntervalMs?: number | false;
150
+ /**
151
+ * AudioContext for audio playback scheduling. If provided, enables jitter buffering.
152
+ * If not provided, audio events are emitted directly without buffering.
153
+ */
154
+ readonly audioContext?: AudioContext;
155
+ /**
156
+ * Jitter buffer configuration. Only used when audioContext is provided.
157
+ */
158
+ readonly jitterBuffer?: Omit<AudioJitterBufferOptions, "sampleRateHz">;
159
+ /**
160
+ * Local barge-in: while assistant audio is playing out, sustained mic energy on
161
+ * the uplink sends {type:"client_interrupt"} so the server stops the agent at
162
+ * local-VAD speed instead of waiting for provider STT evidence. Provider-agnostic
163
+ * (pure PCM energy; works with any STT/TTS). Requires audioContext (playout
164
+ * knowledge) and benefits from getUserMedia echoCancellation. Set false to disable.
165
+ */
166
+ readonly bargeIn?: false | {
167
+ /** Sustained speech required before interrupting. Default 280 ms (mirrors the server gate). */
168
+ readonly minSpeechMs?: number;
169
+ /** Speech threshold as a multiple of the rolling noise floor. Default 3. */
170
+ readonly thresholdFactor?: number;
171
+ /** Absolute RMS floor (0..1 full scale) below which audio is never speech. Default 0.015. */
172
+ readonly minRms?: number;
173
+ };
174
+ }
175
+
176
+ const RECONNECT_MAX_ATTEMPTS = 10;
177
+ const RECONNECT_BASE_DELAY_MS = 1_000;
178
+ const RECONNECT_MAX_DELAY_MS = 30_000;
179
+ const RECONNECT_MIN_STABLE_MS = 5_000;
180
+ const RECONNECT_MAX_QUICK_FAILURES = 3;
181
+ const KEEPALIVE_INTERVAL_MS = 10_000;
182
+
183
+ export class SyrinxBrowserClient {
184
+ private readonly transport: ClientTransport;
185
+ private readonly handlers = new Set<SyrinxBrowserClientHandler>();
186
+ private audioSequence = 0;
187
+ private currentSessionId: string | null = null;
188
+ private cleanClose = false;
189
+ private reconnectAttempt = 0;
190
+ private quickFailures = 0;
191
+ private openedAt = 0;
192
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
193
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
194
+ private jitterBuffer: AudioJitterBuffer | null = null;
195
+ private outputSampleRateHz = 16000;
196
+ private wireCodec: BrowserWireCodec = "pcm_s16le";
197
+ private inputSampleRateHz = 16000;
198
+ private opusCodec: BrowserOpusCodec | null = null;
199
+ private opusLoadFailed = false;
200
+ private codecNegotiation: Promise<void> | null = null;
201
+ private uplinkCodecDecided = false;
202
+ private pendingUplink: Array<() => void> = [];
203
+ private readonly streamingResamplers = new Map<string, StreamingPcm16Resampler>();
204
+ private bargeInNoiseFloorRms = 0.005;
205
+ private bargeInSpeechMs = 0;
206
+ private bargeInInterruptSent = false;
207
+
208
+ constructor(private readonly options: SyrinxBrowserClientOptions) {
209
+ this.transport = options.transport ?? new WebSocketClientTransport({ protocols: options.protocols });
210
+ this.transport.setHandlers({
211
+ onOpen: () => this.handleTransportOpen(),
212
+ onClose: (code, reason) => this.handleTransportClose(code, reason),
213
+ onError: (error) => this.emit({ type: "error", error }),
214
+ onMessage: (data) => this.handleTransportMessage(data),
215
+ onAudio: (data) => this.handleTransportAudio(data),
216
+ });
217
+ if (options.audioContext) {
218
+ this.jitterBuffer = new AudioJitterBuffer(options.audioContext, {
219
+ sampleRateHz: this.outputSampleRateHz,
220
+ ...options.jitterBuffer,
221
+ });
222
+ }
223
+ }
224
+
225
+ get connected(): boolean {
226
+ return this.transport.connected;
227
+ }
228
+
229
+ /** The sessionId received from the server's last `ready` message. Used to resume on reconnect. */
230
+ get sessionId(): string | null {
231
+ return this.currentSessionId;
232
+ }
233
+
234
+ on(handler: SyrinxBrowserClientHandler): () => void {
235
+ this.handlers.add(handler);
236
+ return () => {
237
+ this.handlers.delete(handler);
238
+ };
239
+ }
240
+
241
+ connect(): void {
242
+ if (this.transport.connected) return;
243
+ this.cleanClose = false;
244
+ this.cancelReconnect();
245
+ void loadBrowserOpusModule().catch(() => undefined);
246
+ this.openTransport();
247
+ }
248
+
249
+ close(code?: number, reason?: string): void {
250
+ this.cleanClose = true;
251
+ this.cancelReconnect();
252
+ this.stopKeepalive();
253
+ this.jitterBuffer?.clear();
254
+ this.transport.disconnect(code, reason);
255
+ }
256
+
257
+ sendAudioPcm(
258
+ audio: ArrayBuffer | ArrayBufferView,
259
+ sampleRateHz: number,
260
+ options: { readonly contextId?: string; readonly sequence?: number } = {},
261
+ ): void {
262
+ const bytes = ArrayBuffer.isView(audio)
263
+ ? new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength)
264
+ : new Uint8Array(audio);
265
+ if (bytes.byteLength % 2 !== 0) throw new Error("PCM16 audio payload must contain an even number of bytes");
266
+ const sampleRate = readPositiveSampleRate(sampleRateHz);
267
+ if (options.sequence !== undefined) {
268
+ this.assertAudioSequenceCanAdvance(options.sequence);
269
+ }
270
+ this.observeUplinkForBargeIn(rmsFromPcm16Bytes(bytes), (bytes.byteLength / 2 / sampleRate) * 1000);
271
+ this.scheduleUplink(() => {
272
+ for (const frame of this.encodeUplinkPcm(bytes, sampleRate, options)) {
273
+ this.requireOpenTransport().sendAudio(frame);
274
+ }
275
+ });
276
+ }
277
+
278
+ sendAudioBase64(
279
+ audio: string,
280
+ sampleRateHz: number,
281
+ options: { readonly contextId?: string; readonly sequence?: number } = {},
282
+ ): void {
283
+ this.sendJson({
284
+ type: "audio",
285
+ audio,
286
+ sampleRateHz,
287
+ contextId: options.contextId,
288
+ sequence: this.nextAudioSequence(options.sequence),
289
+ });
290
+ }
291
+
292
+ sendFloat32Audio(input: Float32Array, options: EncodeBrowserAudioOptions): void {
293
+ if (options.sequence !== undefined) {
294
+ this.assertAudioSequenceCanAdvance(options.sequence);
295
+ }
296
+ this.observeUplinkForBargeIn(rmsFromFloat32(input), (input.length / options.fromSampleRateHz) * 1000);
297
+ if (this.wireCodec === "opus" && this.opusCodec) {
298
+ const targetRate = readPositiveSampleRate(options.toSampleRateHz);
299
+ const resampled = resampleFloat32Linear(input, options);
300
+ const pcm = float32ToPcm16(resampled);
301
+ const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
302
+ this.scheduleUplink(() => {
303
+ for (const frame of this.encodeUplinkPcm(bytes, targetRate, options)) {
304
+ this.requireOpenTransport().sendAudio(frame);
305
+ }
306
+ });
307
+ return;
308
+ }
309
+ const sequence = this.nextAudioSequence(options.sequence);
310
+ this.scheduleUplink(() => {
311
+ this.requireOpenTransport().sendAudio(
312
+ encodeBrowserAudioEnvelopeFrame(input, { ...options, sequence }) as Uint8Array<ArrayBuffer>,
313
+ );
314
+ });
315
+ }
316
+
317
+ sendText(text: string): void {
318
+ this.sendJson({ type: "text", text });
319
+ }
320
+
321
+ // Local barge-in: pure-energy speech detection on the uplink, gated on the
322
+ // jitter buffer's playout clock (G12: never the generation clock). One
323
+ // client_interrupt per assistant playout; the noise floor adapts while the
324
+ // mic is below threshold so a hot mic doesn't pin the gate open.
325
+ private observeUplinkForBargeIn(rms: number, durationMs: number): void {
326
+ const config = this.options.bargeIn;
327
+ if (config === false) return;
328
+ const minSpeechMs = config?.minSpeechMs ?? 280;
329
+ const thresholdFactor = config?.thresholdFactor ?? 3;
330
+ const minRms = config?.minRms ?? 0.015;
331
+
332
+ const playingOut = this.jitterBuffer?.isPlayingOut ?? false;
333
+ if (!playingOut) {
334
+ this.bargeInSpeechMs = 0;
335
+ this.bargeInInterruptSent = false;
336
+ }
337
+
338
+ const threshold = Math.max(minRms, this.bargeInNoiseFloorRms * thresholdFactor);
339
+ if (rms < threshold) {
340
+ this.bargeInNoiseFloorRms = this.bargeInNoiseFloorRms * 0.95 + rms * 0.05;
341
+ this.bargeInSpeechMs = 0;
342
+ return;
343
+ }
344
+
345
+ if (!playingOut || this.bargeInInterruptSent) return;
346
+ this.bargeInSpeechMs += durationMs;
347
+ if (this.bargeInSpeechMs < minSpeechMs) return;
348
+ this.bargeInInterruptSent = true;
349
+ const assistantContextId = this.jitterBuffer?.activeContextId ?? undefined;
350
+ this.sendJson({ type: "client_interrupt", ...(assistantContextId ? { assistantContextId } : {}) });
351
+ }
352
+
353
+ sendJson(value: unknown): void {
354
+ this.transport.sendJson(value);
355
+ }
356
+
357
+ private openTransport(): void {
358
+ this.uplinkCodecDecided = false;
359
+ const url = this.currentSessionId !== null
360
+ ? buildResumeUrl(this.options.url, this.currentSessionId)
361
+ : this.options.url;
362
+ this.transport.connect(url);
363
+ }
364
+
365
+ private handleTransportOpen(): void {
366
+ this.openedAt = Date.now();
367
+ if (this.reconnectAttempt > 0) {
368
+ const attempt = this.reconnectAttempt;
369
+ this.reconnectAttempt = 0;
370
+ this.emit({ type: "reconnected", attempt });
371
+ } else {
372
+ this.emit({ type: "open" });
373
+ }
374
+ this.startKeepalive();
375
+ }
376
+
377
+ private handleTransportClose(code: number, reason: string): void {
378
+ this.stopKeepalive();
379
+ if (!this.cleanClose) {
380
+ this.jitterBuffer?.clear();
381
+ }
382
+ if (this.cleanClose) {
383
+ this.emit({ type: "close", code, reason });
384
+ return;
385
+ }
386
+ this.scheduleReconnect(code, reason);
387
+ }
388
+
389
+ private handleTransportMessage(data: unknown): void {
390
+ this.handleJsonMessage(data);
391
+ }
392
+
393
+ private handleTransportAudio(data: ArrayBuffer): void {
394
+ this.handleBinaryMessage(data);
395
+ }
396
+
397
+ private scheduleReconnect(code: number, reason: string): void {
398
+ const opts = this.options.reconnect;
399
+ if (opts === false) {
400
+ this.emit({ type: "close", code, reason });
401
+ return;
402
+ }
403
+
404
+ const maxAttempts = opts?.maxAttempts ?? RECONNECT_MAX_ATTEMPTS;
405
+ const baseDelayMs = opts?.baseDelayMs ?? RECONNECT_BASE_DELAY_MS;
406
+ const maxDelayMs = opts?.maxDelayMs ?? RECONNECT_MAX_DELAY_MS;
407
+ const minStableMs = opts?.minStableMs ?? RECONNECT_MIN_STABLE_MS;
408
+ const maxQuickFailures = opts?.maxQuickFailures ?? RECONNECT_MAX_QUICK_FAILURES;
409
+
410
+ // Quick-failure guard: a socket that opens then dies within minStableMs,
411
+ // repeatedly, won't be fixed by backoff (a flapping/half-broken peer mid-deploy,
412
+ // a bad token accepted-then-rejected). Distinct from a never-opening peer, which
413
+ // the maxAttempts cap handles. A genuinely stable connection resets the count.
414
+ const opened = this.openedAt > 0;
415
+ const stable = opened && Date.now() - this.openedAt >= minStableMs;
416
+ this.openedAt = 0;
417
+ if (stable) {
418
+ this.quickFailures = 0;
419
+ } else if (opened) {
420
+ this.quickFailures += 1;
421
+ if (this.quickFailures >= maxQuickFailures) {
422
+ this.quickFailures = 0;
423
+ this.emit({ type: "close", code, reason });
424
+ return;
425
+ }
426
+ }
427
+
428
+ this.reconnectAttempt += 1;
429
+
430
+ if (maxAttempts > 0 && this.reconnectAttempt > maxAttempts) {
431
+ this.emit({ type: "close", code, reason });
432
+ return;
433
+ }
434
+
435
+ const exponential = Math.min(baseDelayMs * Math.pow(2, this.reconnectAttempt - 1), maxDelayMs);
436
+ const jitter = exponential * 0.2 * Math.random();
437
+ const delayMs = Math.round(exponential + jitter);
438
+
439
+ this.emit({ type: "reconnecting", attempt: this.reconnectAttempt, delayMs });
440
+
441
+ this.reconnectTimer = setTimeout(() => {
442
+ this.reconnectTimer = null;
443
+ if (this.cleanClose) return;
444
+ this.openTransport();
445
+ }, delayMs);
446
+ }
447
+
448
+ private cancelReconnect(): void {
449
+ if (this.reconnectTimer !== null) {
450
+ clearTimeout(this.reconnectTimer);
451
+ this.reconnectTimer = null;
452
+ }
453
+ this.reconnectAttempt = 0;
454
+ this.quickFailures = 0;
455
+ this.openedAt = 0;
456
+ }
457
+
458
+ private startKeepalive(): void {
459
+ this.stopKeepalive();
460
+ const intervalMs = this.options.keepaliveIntervalMs;
461
+ if (intervalMs === false) return;
462
+ const ms = typeof intervalMs === "number" && intervalMs > 0 ? intervalMs : KEEPALIVE_INTERVAL_MS;
463
+ this.keepaliveTimer = setInterval(() => {
464
+ if (this.transport.connected) {
465
+ try {
466
+ this.transport.sendJson({ type: "ping" });
467
+ } catch (error) {
468
+ this.stopKeepalive();
469
+ this.emit({
470
+ type: "error",
471
+ error: error instanceof Error ? error : new Error(String(error)),
472
+ });
473
+ }
474
+ } else {
475
+ this.stopKeepalive();
476
+ }
477
+ }, ms);
478
+ }
479
+
480
+ private stopKeepalive(): void {
481
+ if (this.keepaliveTimer !== null) {
482
+ clearInterval(this.keepaliveTimer);
483
+ this.keepaliveTimer = null;
484
+ }
485
+ }
486
+
487
+ private handleJsonMessage(data: unknown): void {
488
+ if (typeof data !== "string") return;
489
+ try {
490
+ const message = parseStudioMessage(JSON.parse(data) as unknown);
491
+ if (message.type === "ready") {
492
+ if (message.sessionId !== undefined) this.currentSessionId = message.sessionId;
493
+ if (message.audio?.outputSampleRateHz && message.audio.outputSampleRateHz !== this.outputSampleRateHz) {
494
+ this.outputSampleRateHz = message.audio.outputSampleRateHz;
495
+ if (this.options.audioContext) {
496
+ this.jitterBuffer = new AudioJitterBuffer(this.options.audioContext, {
497
+ sampleRateHz: this.outputSampleRateHz,
498
+ ...this.options.jitterBuffer,
499
+ });
500
+ }
501
+ }
502
+ if (message.audio?.supportedInputCodecs?.includes("opus") && !this.opusLoadFailed) {
503
+ this.codecNegotiation = this.negotiateWireCodec(message.audio).finally(() => {
504
+ this.codecNegotiation = null;
505
+ this.flushPendingUplink();
506
+ });
507
+ } else {
508
+ void this.negotiateWireCodec(message.audio).finally(() => {
509
+ this.flushPendingUplink();
510
+ });
511
+ }
512
+ }
513
+ if ((message.type === "audio_clear" || message.type === "agent_interrupted") && this.jitterBuffer) {
514
+ this.jitterBuffer.clear(message.turnId);
515
+ }
516
+ this.emit({ type: "message", message });
517
+ if (message.type === "ready" && message.resumed === true) {
518
+ this.emit({ type: "resumed" });
519
+ }
520
+ } catch (err) {
521
+ this.emit({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
522
+ }
523
+ }
524
+
525
+ private handleBinaryMessage(data: ArrayBuffer): void {
526
+ try {
527
+ const audio = decodeBrowserAssistantAudio(data, this.opusCodec);
528
+ if (audio.data.byteLength === 0) return;
529
+ let pcmData = audio.data;
530
+ const wireRate = audio.metadata?.sampleRateHz;
531
+ if (wireRate !== undefined && wireRate !== this.outputSampleRateHz) {
532
+ const samples = pcm16BytesToSamples(new Uint8Array(pcmData));
533
+ const resampled = pcm16SamplesToBytes(
534
+ resamplePcm16Streaming(this.streamingResamplers, samples, wireRate, this.outputSampleRateHz),
535
+ );
536
+ const copy = new Uint8Array(resampled.byteLength);
537
+ copy.set(resampled);
538
+ pcmData = copy.buffer;
539
+ }
540
+ this.handleAudioData(pcmData, audio.metadata);
541
+ } catch (err) {
542
+ this.emit({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
543
+ }
544
+ }
545
+
546
+ private scheduleUplink(send: () => void): void {
547
+ if (!this.uplinkCodecDecided || this.codecNegotiation) {
548
+ this.pendingUplink.push(send);
549
+ return;
550
+ }
551
+ send();
552
+ }
553
+
554
+ private flushPendingUplink(): void {
555
+ this.uplinkCodecDecided = true;
556
+ const pending = this.pendingUplink;
557
+ this.pendingUplink = [];
558
+ for (const run of pending) run();
559
+ }
560
+
561
+ private async negotiateWireCodec(audio: SyrinxReadyAudio): Promise<void> {
562
+ if (!audio) return;
563
+ this.inputSampleRateHz = audio.inputSampleRateHz;
564
+ const supported = audio.supportedInputCodecs;
565
+ if (!supported?.includes("opus") || this.opusLoadFailed) {
566
+ this.wireCodec = "pcm_s16le";
567
+ this.reportDownlinkCodecCapability();
568
+ return;
569
+ }
570
+ try {
571
+ this.opusCodec = await createBrowserOpusCodec(BROWSER_OPUS_SAMPLE_RATE_HZ);
572
+ this.wireCodec = pickBrowserWireCodec(supported, true);
573
+ } catch {
574
+ this.opusLoadFailed = true;
575
+ this.opusCodec = null;
576
+ this.wireCodec = "pcm_s16le";
577
+ }
578
+ this.reportDownlinkCodecCapability();
579
+ }
580
+
581
+ private reportDownlinkCodecCapability(): void {
582
+ const downlinkEncoding = this.opusCodec ? "opus" : "pcm_s16le";
583
+ this.sendJson({ type: "codec_capability", downlinkEncoding });
584
+ }
585
+
586
+ private encodeUplinkPcm(
587
+ bytes: Uint8Array,
588
+ sampleRateHz: number,
589
+ options: { readonly contextId?: string; readonly sequence?: number },
590
+ ): Uint8Array[] {
591
+ if (this.wireCodec === "opus" && this.opusCodec) {
592
+ const frames: Uint8Array[] = [];
593
+ let seq: number | undefined = options.sequence;
594
+ const samples = pcm16BytesToSamples(bytes);
595
+ const wireSamples = sampleRateHz === this.opusCodec.sampleRateHz
596
+ ? samples
597
+ : resamplePcm16Streaming(this.streamingResamplers, samples, sampleRateHz, this.opusCodec.sampleRateHz);
598
+ for (const opus of this.opusCodec.encodePcm16Frame(wireSamples, true)) {
599
+ const sequence = this.nextAudioSequence(seq);
600
+ frames.push(encodeBrowserOpusEnvelope(opus, this.opusCodec.sampleRateHz, { ...options, sequence }));
601
+ seq = sequence;
602
+ }
603
+ return frames;
604
+ }
605
+ const sequence = this.nextAudioSequence(options.sequence);
606
+ return [encodeBrowserPcmEnvelope(bytes, sampleRateHz, { ...options, sequence })];
607
+ }
608
+
609
+ private requireOpenTransport(): ClientTransport {
610
+ if (!this.transport.connected) {
611
+ throw new Error("SyrinxBrowserClient transport is not connected");
612
+ }
613
+ return this.transport;
614
+ }
615
+
616
+ private assertAudioSequenceCanAdvance(sequence: number): void {
617
+ if (!Number.isInteger(sequence) || sequence < 0) {
618
+ throw new Error("audio sequence must be a non-negative integer");
619
+ }
620
+ if (sequence <= this.audioSequence) {
621
+ throw new Error(`audio sequence must increase monotonically: ${String(this.audioSequence)} -> ${String(sequence)}`);
622
+ }
623
+ }
624
+
625
+ private nextAudioSequence(sequence: number | undefined): number {
626
+ if (sequence !== undefined) {
627
+ if (!Number.isInteger(sequence) || sequence < 0) throw new Error("audio sequence must be a non-negative integer");
628
+ if (sequence <= this.audioSequence) {
629
+ throw new Error(`audio sequence must increase monotonically: ${String(this.audioSequence)} -> ${String(sequence)}`);
630
+ }
631
+ this.audioSequence = Math.max(this.audioSequence, sequence);
632
+ return sequence;
633
+ }
634
+ this.audioSequence += 1;
635
+ return this.audioSequence;
636
+ }
637
+
638
+ private handleAudioData(data: ArrayBuffer, metadata?: BrowserAssistantAudio["metadata"]): void {
639
+ if (this.jitterBuffer) {
640
+ // Use jitter buffer for scheduled playback
641
+ this.jitterBuffer.enqueue(data, metadata?.contextId);
642
+ }
643
+ // Always emit the audio event for applications that want raw access
644
+ this.emit({ type: "audio", data, metadata });
645
+ }
646
+
647
+ private emit(event: SyrinxBrowserClientEvent): void {
648
+ for (const handler of this.handlers) {
649
+ handler(event);
650
+ }
651
+ }
652
+ }
653
+
654
+ function buildResumeUrl(baseUrl: string, sessionId: string): string {
655
+ try {
656
+ const url = new URL(baseUrl);
657
+ url.searchParams.set("sessionId", sessionId);
658
+ return url.toString();
659
+ } catch {
660
+ const sep = baseUrl.includes("?") ? "&" : "?";
661
+ return `${baseUrl}${sep}sessionId=${encodeURIComponent(sessionId)}`;
662
+ }
663
+ }
664
+
665
+ function readPositiveSampleRate(value: number): number {
666
+ if (!Number.isInteger(value) || value <= 0) throw new Error("sampleRateHz must be a positive integer");
667
+ return value;
668
+ }
669
+
670
+ function parseStudioMessage(value: unknown): SyrinxStudioMessage {
671
+ if (!isRecord(value)) throw new Error("Syrinx websocket message must be an object");
672
+ const type = requiredString(value.type, "Syrinx websocket message type");
673
+ if (type === "ready") {
674
+ return {
675
+ type,
676
+ sessionId: optionalString(value.sessionId, "ready.sessionId"),
677
+ resumed: optionalBoolean(value.resumed, "ready.resumed"),
678
+ resumeWindowMs: optionalNumber(value.resumeWindowMs, "ready.resumeWindowMs"),
679
+ audio: parseReadyAudio(value.audio),
680
+ };
681
+ }
682
+ if (type === "speech_started" || type === "speech_ended" || type === "agent_end" || type === "tts_end") {
683
+ return { type, turnId: optionalString(value.turnId, `${type}.turnId`) };
684
+ }
685
+ if (type === "stt_chunk" || type === "stt_output") {
686
+ return {
687
+ type,
688
+ turnId: optionalString(value.turnId, `${type}.turnId`),
689
+ transcript: requiredString(value.transcript, `${type}.transcript`),
690
+ ...(type === "stt_output" ? { confidence: optionalNumber(value.confidence, "stt_output.confidence") } : {}),
691
+ } as SyrinxStudioMessage;
692
+ }
693
+ if (type === "agent_chunk") {
694
+ return {
695
+ type,
696
+ turnId: optionalString(value.turnId, "agent_chunk.turnId"),
697
+ text: requiredString(value.text, "agent_chunk.text"),
698
+ };
699
+ }
700
+ if (type === "agent_tool_call") {
701
+ return {
702
+ type,
703
+ turnId: optionalString(value.turnId, "agent_tool_call.turnId"),
704
+ id: optionalString(value.id, "agent_tool_call.id"),
705
+ name: requiredString(value.name, "agent_tool_call.name"),
706
+ args: value.args,
707
+ };
708
+ }
709
+ if (type === "agent_tool_result") {
710
+ return {
711
+ type,
712
+ turnId: optionalString(value.turnId, "agent_tool_result.turnId"),
713
+ id: optionalString(value.id, "agent_tool_result.id"),
714
+ result: value.result,
715
+ };
716
+ }
717
+ if (type === "agent_interrupted" || type === "audio_clear") {
718
+ return {
719
+ type,
720
+ turnId: optionalString(value.turnId, `${type}.turnId`),
721
+ reason: optionalString(value.reason, `${type}.reason`),
722
+ };
723
+ }
724
+ if (type === "tts_chunk") {
725
+ return {
726
+ type,
727
+ turnId: optionalString(value.turnId, "tts_chunk.turnId"),
728
+ sequence: requiredNonNegativeInteger(value.sequence, "tts_chunk.sequence"),
729
+ sampleRateHz: requiredPositiveInteger(value.sampleRateHz, "tts_chunk.sampleRateHz"),
730
+ encoding: requiredLiteral(value.encoding, "pcm_s16le", "tts_chunk.encoding"),
731
+ channels: requiredLiteral(value.channels, 1, "tts_chunk.channels"),
732
+ byteLength: requiredNonNegativeInteger(value.byteLength, "tts_chunk.byteLength"),
733
+ durationMs: requiredNonNegativeInteger(value.durationMs, "tts_chunk.durationMs"),
734
+ };
735
+ }
736
+ if (type === "metrics") {
737
+ return {
738
+ type,
739
+ turnId: optionalString(value.turnId, "metrics.turnId"),
740
+ correlationId: optionalString(value.correlationId, "metrics.correlationId"),
741
+ speechEndMs: optionalNumber(value.speechEndMs, "metrics.speechEndMs"),
742
+ textReadyMs: optionalNumber(value.textReadyMs, "metrics.textReadyMs"),
743
+ firstAudioByteMs: optionalNumber(value.firstAudioByteMs, "metrics.firstAudioByteMs"),
744
+ firstAudioPlayedMs: optionalNumber(value.firstAudioPlayedMs, "metrics.firstAudioPlayedMs"),
745
+ lastAudioPlayedMs: optionalNumber(value.lastAudioPlayedMs, "metrics.lastAudioPlayedMs"),
746
+ sttMs: optionalNumber(value.sttMs, "metrics.sttMs"),
747
+ llmTTFTMs: optionalNumber(value.llmTTFTMs, "metrics.llmTTFTMs"),
748
+ ttsTTFBMs: optionalNumber(value.ttsTTFBMs, "metrics.ttsTTFBMs"),
749
+ e2eMs: optionalNumber(value.e2eMs, "metrics.e2eMs"),
750
+ };
751
+ }
752
+ if (type === "error") {
753
+ return {
754
+ type,
755
+ component: optionalString(value.component, "error.component"),
756
+ category: optionalString(value.category, "error.category"),
757
+ message: requiredString(value.message, "error.message"),
758
+ };
759
+ }
760
+ if (type === "turn_complete") {
761
+ return {
762
+ type,
763
+ turnId: optionalString(value.turnId, "turn_complete.turnId"),
764
+ transcript: typeof value.transcript === "string" ? value.transcript : "",
765
+ };
766
+ }
767
+ throw new Error(`Unsupported Syrinx websocket message type: ${type}`);
768
+ }
769
+
770
+ function parseReadyAudio(value: unknown): SyrinxReadyAudio {
771
+ if (value === undefined) return undefined;
772
+ if (!isRecord(value)) throw new Error("ready.audio must be an object");
773
+ const encoding = value.encoding;
774
+ if (encoding !== "pcm_s16le" && encoding !== "opus") {
775
+ throw new Error("ready.audio.encoding must be pcm_s16le or opus");
776
+ }
777
+ return {
778
+ inputSampleRateHz: requiredPositiveInteger(value.inputSampleRateHz, "ready.audio.inputSampleRateHz"),
779
+ outputSampleRateHz: requiredPositiveInteger(value.outputSampleRateHz, "ready.audio.outputSampleRateHz"),
780
+ encoding,
781
+ supportedInputCodecs: parseSupportedInputCodecs(value.supportedInputCodecs),
782
+ channels: requiredLiteral(value.channels, 1, "ready.audio.channels"),
783
+ binaryEnvelope: value.binaryEnvelope === undefined
784
+ ? undefined
785
+ : requiredLiteral(value.binaryEnvelope, "syrinx.audio.v1", "ready.audio.binaryEnvelope"),
786
+ rawBinaryInput: optionalBoolean(value.rawBinaryInput, "ready.audio.rawBinaryInput"),
787
+ };
788
+ }
789
+
790
+ function parseSupportedInputCodecs(value: unknown): readonly string[] | undefined {
791
+ if (value === undefined) return undefined;
792
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
793
+ throw new Error("ready.audio.supportedInputCodecs must be an array of strings");
794
+ }
795
+ return value;
796
+ }
797
+
798
+ function isRecord(value: unknown): value is Record<string, unknown> {
799
+ return typeof value === "object" && value !== null && !Array.isArray(value);
800
+ }
801
+
802
+ function requiredString(value: unknown, name: string): string {
803
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
804
+ return value;
805
+ }
806
+
807
+ function rmsFromPcm16Bytes(bytes: Uint8Array): number {
808
+ const sampleCount = Math.floor(bytes.byteLength / 2);
809
+ if (sampleCount === 0) return 0;
810
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, sampleCount);
811
+ let sumSquares = 0;
812
+ for (const sample of samples) {
813
+ const normalized = sample / 32768;
814
+ sumSquares += normalized * normalized;
815
+ }
816
+ return Math.sqrt(sumSquares / sampleCount);
817
+ }
818
+
819
+ function rmsFromFloat32(samples: Float32Array): number {
820
+ if (samples.length === 0) return 0;
821
+ let sumSquares = 0;
822
+ for (const sample of samples) {
823
+ sumSquares += sample * sample;
824
+ }
825
+ return Math.sqrt(sumSquares / samples.length);
826
+ }
827
+
828
+ function optionalString(value: unknown, name: string): string | undefined {
829
+ // An optional field that arrives empty/null carries no value — treat as absent rather than
830
+ // throwing. (Servers may emit e.g. `tts_end.turnId: ""` when a turn has no context id.)
831
+ if (value === undefined || value === null || value === "") return undefined;
832
+ return requiredString(value, name);
833
+ }
834
+
835
+ function requiredPositiveInteger(value: unknown, name: string): number {
836
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
837
+ throw new Error(`${name} must be a positive integer`);
838
+ }
839
+ return value;
840
+ }
841
+
842
+ function requiredNonNegativeInteger(value: unknown, name: string): number {
843
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
844
+ throw new Error(`${name} must be a non-negative integer`);
845
+ }
846
+ return value;
847
+ }
848
+
849
+ function optionalNumber(value: unknown, name: string): number | undefined {
850
+ if (value === undefined) return undefined;
851
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
852
+ return value;
853
+ }
854
+
855
+ function optionalBoolean(value: unknown, name: string): boolean | undefined {
856
+ if (value === undefined) return undefined;
857
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
858
+ return value;
859
+ }
860
+
861
+ function requiredLiteral<T extends string | number>(value: unknown, expected: T, name: string): T {
862
+ if (value !== expected) throw new Error(`${name} must be ${String(expected)}`);
863
+ return expected;
864
+ }