@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/dist/index.js ADDED
@@ -0,0 +1,744 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export { decodeBrowserAssistantAudio, encodeBrowserAudioEnvelopeFrame, encodeBrowserAudioFrame, float32ToPcm16, pcm16FrameSampleCount, resampleFloat32Linear, } from "./audio.js";
3
+ import { createBrowserOpusCodec, encodeBrowserOpusEnvelope, encodeBrowserPcmEnvelope, loadBrowserOpusModule, pickBrowserWireCodec, } from "./browser-opus.js";
4
+ import { BROWSER_OPUS_SAMPLE_RATE_HZ } from "./browser-opus.js";
5
+ import { pcm16BytesToSamples, pcm16SamplesToBytes, resamplePcm16Streaming, } from "@kuralle-syrinx/core/audio";
6
+ import { decodeBrowserAssistantAudio, encodeBrowserAudioEnvelopeFrame, float32ToPcm16, resampleFloat32Linear, AudioJitterBuffer, } from "./audio.js";
7
+ import { WebSocketClientTransport } from "./websocket-transport.js";
8
+ export { WebSocketClientTransport } from "./websocket-transport.js";
9
+ export { BROWSER_OPUS_FRAME_DURATION_MS, BROWSER_OPUS_SAMPLE_RATE_HZ, BROWSER_SUPPORTED_INPUT_CODECS, createBrowserOpusCodec, encodeBrowserOpusEnvelope, encodeBrowserPcmEnvelope, loadBrowserOpusModule, pickBrowserWireCodec, } from "./browser-opus.js";
10
+ const RECONNECT_MAX_ATTEMPTS = 10;
11
+ const RECONNECT_BASE_DELAY_MS = 1_000;
12
+ const RECONNECT_MAX_DELAY_MS = 30_000;
13
+ const RECONNECT_MIN_STABLE_MS = 5_000;
14
+ const RECONNECT_MAX_QUICK_FAILURES = 3;
15
+ const KEEPALIVE_INTERVAL_MS = 10_000;
16
+ // Endpointing decision values carried on the `metrics` message. Forward-compatible:
17
+ // an unknown value (a newer backend) is ignored rather than dropping the message.
18
+ const ENDPOINTING_OWNERS = new Set(["provider_stt", "smart_turn", "timer", "text"]);
19
+ const ENDPOINTING_REASONS = new Set(["end_of_speech", "force_finalized", "typed"]);
20
+ export class SyrinxBrowserClient {
21
+ options;
22
+ transport;
23
+ handlers = new Set();
24
+ audioSequence = 0;
25
+ currentSessionId = null;
26
+ cleanClose = false;
27
+ reconnectAttempt = 0;
28
+ quickFailures = 0;
29
+ openedAt = 0;
30
+ reconnectTimer = null;
31
+ keepaliveTimer = null;
32
+ playoutProgressTimer = null;
33
+ lastProgressContextId = null;
34
+ jitterBuffer = null;
35
+ outputSampleRateHz = 16000;
36
+ wireCodec = "pcm_s16le";
37
+ inputSampleRateHz = 16000;
38
+ opusCodec = null;
39
+ opusLoadFailed = false;
40
+ codecNegotiation = null;
41
+ uplinkCodecDecided = false;
42
+ pendingUplink = [];
43
+ streamingResamplers = new Map();
44
+ bargeInNoiseFloorRms = 0.005;
45
+ bargeInSpeechMs = 0;
46
+ bargeInInterruptSent = false;
47
+ constructor(options) {
48
+ this.options = options;
49
+ this.transport = options.transport ?? new WebSocketClientTransport({ protocols: options.protocols });
50
+ this.transport.setHandlers({
51
+ onOpen: () => this.handleTransportOpen(),
52
+ onClose: (code, reason) => this.handleTransportClose(code, reason),
53
+ onError: (error) => this.emit({ type: "error", error }),
54
+ onMessage: (data) => this.handleTransportMessage(data),
55
+ onAudio: (data) => this.handleTransportAudio(data),
56
+ });
57
+ if (options.audioContext) {
58
+ this.jitterBuffer = new AudioJitterBuffer(options.audioContext, {
59
+ sampleRateHz: this.outputSampleRateHz,
60
+ ...options.jitterBuffer,
61
+ });
62
+ }
63
+ }
64
+ get connected() {
65
+ return this.transport.connected;
66
+ }
67
+ /** The sessionId received from the server's last `ready` message. Used to resume on reconnect. */
68
+ get sessionId() {
69
+ return this.currentSessionId;
70
+ }
71
+ on(handler) {
72
+ this.handlers.add(handler);
73
+ return () => {
74
+ this.handlers.delete(handler);
75
+ };
76
+ }
77
+ connect() {
78
+ if (this.transport.connected)
79
+ return;
80
+ this.cleanClose = false;
81
+ this.cancelReconnect();
82
+ void loadBrowserOpusModule().catch(() => undefined);
83
+ this.openTransport();
84
+ }
85
+ close(code, reason) {
86
+ this.cleanClose = true;
87
+ this.cancelReconnect();
88
+ this.stopKeepalive();
89
+ this.jitterBuffer?.clear();
90
+ this.transport.disconnect(code, reason);
91
+ }
92
+ sendAudioPcm(audio, sampleRateHz, options = {}) {
93
+ const bytes = ArrayBuffer.isView(audio)
94
+ ? new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength)
95
+ : new Uint8Array(audio);
96
+ if (bytes.byteLength % 2 !== 0)
97
+ throw new Error("PCM16 audio payload must contain an even number of bytes");
98
+ const sampleRate = readPositiveSampleRate(sampleRateHz);
99
+ if (options.sequence !== undefined) {
100
+ this.assertAudioSequenceCanAdvance(options.sequence);
101
+ }
102
+ this.observeUplinkForBargeIn(rmsFromPcm16Bytes(bytes), (bytes.byteLength / 2 / sampleRate) * 1000);
103
+ this.scheduleUplink(() => {
104
+ for (const frame of this.encodeUplinkPcm(bytes, sampleRate, options)) {
105
+ this.requireOpenTransport().sendAudio(frame);
106
+ }
107
+ });
108
+ }
109
+ sendAudioBase64(audio, sampleRateHz, options = {}) {
110
+ this.sendJson({
111
+ type: "audio",
112
+ audio,
113
+ sampleRateHz,
114
+ contextId: options.contextId,
115
+ sequence: this.nextAudioSequence(options.sequence),
116
+ });
117
+ }
118
+ sendFloat32Audio(input, options) {
119
+ if (options.sequence !== undefined) {
120
+ this.assertAudioSequenceCanAdvance(options.sequence);
121
+ }
122
+ this.observeUplinkForBargeIn(rmsFromFloat32(input), (input.length / options.fromSampleRateHz) * 1000);
123
+ if (this.wireCodec === "opus" && this.opusCodec) {
124
+ const targetRate = readPositiveSampleRate(options.toSampleRateHz);
125
+ const resampled = resampleFloat32Linear(input, options);
126
+ const pcm = float32ToPcm16(resampled);
127
+ const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
128
+ this.scheduleUplink(() => {
129
+ for (const frame of this.encodeUplinkPcm(bytes, targetRate, options)) {
130
+ this.requireOpenTransport().sendAudio(frame);
131
+ }
132
+ });
133
+ return;
134
+ }
135
+ const sequence = this.nextAudioSequence(options.sequence);
136
+ this.scheduleUplink(() => {
137
+ this.requireOpenTransport().sendAudio(encodeBrowserAudioEnvelopeFrame(input, { ...options, sequence }));
138
+ });
139
+ }
140
+ sendText(text) {
141
+ this.sendJson({ type: "text", text });
142
+ }
143
+ // Local barge-in: pure-energy speech detection on the uplink, gated on the
144
+ // jitter buffer's playout clock (G12: never the generation clock). One
145
+ // client_interrupt per assistant playout; the noise floor adapts while the
146
+ // mic is below threshold so a hot mic doesn't pin the gate open.
147
+ observeUplinkForBargeIn(rms, durationMs) {
148
+ const config = this.options.bargeIn;
149
+ if (config === false)
150
+ return;
151
+ const minSpeechMs = config?.minSpeechMs ?? 280;
152
+ const thresholdFactor = config?.thresholdFactor ?? 3;
153
+ const minRms = config?.minRms ?? 0.015;
154
+ const playingOut = this.jitterBuffer?.isPlayingOut ?? false;
155
+ if (!playingOut) {
156
+ this.bargeInSpeechMs = 0;
157
+ this.bargeInInterruptSent = false;
158
+ }
159
+ const threshold = Math.max(minRms, this.bargeInNoiseFloorRms * thresholdFactor);
160
+ if (rms < threshold) {
161
+ this.bargeInNoiseFloorRms = this.bargeInNoiseFloorRms * 0.95 + rms * 0.05;
162
+ this.bargeInSpeechMs = 0;
163
+ return;
164
+ }
165
+ if (!playingOut || this.bargeInInterruptSent)
166
+ return;
167
+ this.bargeInSpeechMs += durationMs;
168
+ if (this.bargeInSpeechMs < minSpeechMs)
169
+ return;
170
+ this.bargeInInterruptSent = true;
171
+ const assistantContextId = this.jitterBuffer?.activeContextId ?? undefined;
172
+ this.sendJson({ type: "client_interrupt", ...(assistantContextId ? { assistantContextId } : {}) });
173
+ }
174
+ sendJson(value) {
175
+ this.transport.sendJson(value);
176
+ }
177
+ openTransport() {
178
+ this.uplinkCodecDecided = false;
179
+ const url = this.currentSessionId !== null
180
+ ? buildResumeUrl(this.options.url, this.currentSessionId)
181
+ : this.options.url;
182
+ this.transport.connect(url);
183
+ }
184
+ handleTransportOpen() {
185
+ this.openedAt = Date.now();
186
+ if (this.reconnectAttempt > 0) {
187
+ const attempt = this.reconnectAttempt;
188
+ this.reconnectAttempt = 0;
189
+ this.emit({ type: "reconnected", attempt });
190
+ }
191
+ else {
192
+ this.emit({ type: "open" });
193
+ }
194
+ this.startKeepalive();
195
+ this.startPlayoutProgressReporting();
196
+ }
197
+ handleTransportClose(code, reason) {
198
+ this.stopKeepalive();
199
+ this.stopPlayoutProgressReporting();
200
+ if (!this.cleanClose) {
201
+ this.jitterBuffer?.clear();
202
+ }
203
+ if (this.cleanClose) {
204
+ this.emit({ type: "close", code, reason });
205
+ return;
206
+ }
207
+ this.scheduleReconnect(code, reason);
208
+ }
209
+ handleTransportMessage(data) {
210
+ this.handleJsonMessage(data);
211
+ }
212
+ handleTransportAudio(data) {
213
+ this.handleBinaryMessage(data);
214
+ }
215
+ scheduleReconnect(code, reason) {
216
+ const opts = this.options.reconnect;
217
+ if (opts === false) {
218
+ this.emit({ type: "close", code, reason });
219
+ return;
220
+ }
221
+ const maxAttempts = opts?.maxAttempts ?? RECONNECT_MAX_ATTEMPTS;
222
+ const baseDelayMs = opts?.baseDelayMs ?? RECONNECT_BASE_DELAY_MS;
223
+ const maxDelayMs = opts?.maxDelayMs ?? RECONNECT_MAX_DELAY_MS;
224
+ const minStableMs = opts?.minStableMs ?? RECONNECT_MIN_STABLE_MS;
225
+ const maxQuickFailures = opts?.maxQuickFailures ?? RECONNECT_MAX_QUICK_FAILURES;
226
+ // Quick-failure guard: a socket that opens then dies within minStableMs,
227
+ // repeatedly, won't be fixed by backoff (a flapping/half-broken peer mid-deploy,
228
+ // a bad token accepted-then-rejected). Distinct from a never-opening peer, which
229
+ // the maxAttempts cap handles. A genuinely stable connection resets the count.
230
+ const opened = this.openedAt > 0;
231
+ const stable = opened && Date.now() - this.openedAt >= minStableMs;
232
+ this.openedAt = 0;
233
+ if (stable) {
234
+ this.quickFailures = 0;
235
+ }
236
+ else if (opened) {
237
+ this.quickFailures += 1;
238
+ if (this.quickFailures >= maxQuickFailures) {
239
+ this.quickFailures = 0;
240
+ this.emit({ type: "close", code, reason });
241
+ return;
242
+ }
243
+ }
244
+ this.reconnectAttempt += 1;
245
+ if (maxAttempts > 0 && this.reconnectAttempt > maxAttempts) {
246
+ this.emit({ type: "close", code, reason });
247
+ return;
248
+ }
249
+ const exponential = Math.min(baseDelayMs * Math.pow(2, this.reconnectAttempt - 1), maxDelayMs);
250
+ const jitter = exponential * 0.2 * Math.random();
251
+ const delayMs = Math.round(exponential + jitter);
252
+ this.emit({ type: "reconnecting", attempt: this.reconnectAttempt, delayMs });
253
+ this.reconnectTimer = setTimeout(() => {
254
+ this.reconnectTimer = null;
255
+ if (this.cleanClose)
256
+ return;
257
+ this.openTransport();
258
+ }, delayMs);
259
+ }
260
+ cancelReconnect() {
261
+ if (this.reconnectTimer !== null) {
262
+ clearTimeout(this.reconnectTimer);
263
+ this.reconnectTimer = null;
264
+ }
265
+ this.reconnectAttempt = 0;
266
+ this.quickFailures = 0;
267
+ this.openedAt = 0;
268
+ }
269
+ startKeepalive() {
270
+ this.stopKeepalive();
271
+ const intervalMs = this.options.keepaliveIntervalMs;
272
+ if (intervalMs === false)
273
+ return;
274
+ const ms = typeof intervalMs === "number" && intervalMs > 0 ? intervalMs : KEEPALIVE_INTERVAL_MS;
275
+ this.keepaliveTimer = setInterval(() => {
276
+ if (this.transport.connected) {
277
+ try {
278
+ this.transport.sendJson({ type: "ping" });
279
+ }
280
+ catch (error) {
281
+ this.stopKeepalive();
282
+ this.emit({
283
+ type: "error",
284
+ error: error instanceof Error ? error : new Error(String(error)),
285
+ });
286
+ }
287
+ }
288
+ else {
289
+ this.stopKeepalive();
290
+ }
291
+ }, ms);
292
+ }
293
+ stopKeepalive() {
294
+ if (this.keepaliveTimer !== null) {
295
+ clearInterval(this.keepaliveTimer);
296
+ this.keepaliveTimer = null;
297
+ }
298
+ }
299
+ // The browser is the playout clock on the WebSocket media path: report how many
300
+ // ms of the current assistant turn have actually reached the speaker, so the
301
+ // server can truncate conversation history to what the user HEARD on barge-in
302
+ // (B2) instead of falling back to a stale 0. ~200ms cadence matches the server
303
+ // emitter's throttle; a final complete:true is sent when a turn finishes playing.
304
+ startPlayoutProgressReporting() {
305
+ this.stopPlayoutProgressReporting();
306
+ if (!this.jitterBuffer)
307
+ return;
308
+ this.playoutProgressTimer = setInterval(() => {
309
+ const jitter = this.jitterBuffer;
310
+ if (!jitter || !this.transport.connected)
311
+ return;
312
+ const contextId = jitter.activeContextId ?? this.lastProgressContextId;
313
+ if (!contextId)
314
+ return;
315
+ const complete = jitter.isPlayoutComplete(contextId);
316
+ try {
317
+ this.sendJson({
318
+ type: "playout_progress",
319
+ contextId,
320
+ playedOutMs: Math.round(jitter.playedOutMs(contextId)),
321
+ complete,
322
+ });
323
+ }
324
+ catch {
325
+ this.stopPlayoutProgressReporting();
326
+ return;
327
+ }
328
+ this.lastProgressContextId = complete ? null : contextId;
329
+ }, 200);
330
+ }
331
+ stopPlayoutProgressReporting() {
332
+ if (this.playoutProgressTimer !== null) {
333
+ clearInterval(this.playoutProgressTimer);
334
+ this.playoutProgressTimer = null;
335
+ }
336
+ this.lastProgressContextId = null;
337
+ }
338
+ handleJsonMessage(data) {
339
+ if (typeof data !== "string")
340
+ return;
341
+ try {
342
+ const parsed = JSON.parse(data);
343
+ // Cloudflare Agents SDK control frames (cf_agent_state, cf_agent_mcp_servers, …) are
344
+ // broadcast by the Agent base class on the same socket a cf-agents `withVoice` worker
345
+ // uses for voice. They are not part of the Syrinx voice protocol — ignore them rather
346
+ // than throwing (which surfaced a spurious `error` event → a false "Error" badge).
347
+ if (isRecord(parsed) && typeof parsed["type"] === "string" && parsed["type"].startsWith("cf_agent")) {
348
+ return;
349
+ }
350
+ const message = parseStudioMessage(parsed);
351
+ if (message.type === "ready") {
352
+ if (message.sessionId !== undefined)
353
+ this.currentSessionId = message.sessionId;
354
+ if (message.audio?.outputSampleRateHz && message.audio.outputSampleRateHz !== this.outputSampleRateHz) {
355
+ this.outputSampleRateHz = message.audio.outputSampleRateHz;
356
+ if (this.options.audioContext) {
357
+ this.jitterBuffer = new AudioJitterBuffer(this.options.audioContext, {
358
+ sampleRateHz: this.outputSampleRateHz,
359
+ ...this.options.jitterBuffer,
360
+ });
361
+ }
362
+ }
363
+ if (message.audio?.supportedInputCodecs?.includes("opus") && !this.opusLoadFailed) {
364
+ this.codecNegotiation = this.negotiateWireCodec(message.audio).finally(() => {
365
+ this.codecNegotiation = null;
366
+ this.flushPendingUplink();
367
+ });
368
+ }
369
+ else {
370
+ void this.negotiateWireCodec(message.audio).finally(() => {
371
+ this.flushPendingUplink();
372
+ });
373
+ }
374
+ }
375
+ if ((message.type === "audio_clear" || message.type === "agent_interrupted") && this.jitterBuffer) {
376
+ this.jitterBuffer.clear(message.turnId);
377
+ }
378
+ this.emit({ type: "message", message });
379
+ if (message.type === "ready" && message.resumed === true) {
380
+ this.emit({ type: "resumed" });
381
+ }
382
+ }
383
+ catch (err) {
384
+ this.emit({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
385
+ }
386
+ }
387
+ handleBinaryMessage(data) {
388
+ try {
389
+ const audio = decodeBrowserAssistantAudio(data, this.opusCodec);
390
+ if (audio.data.byteLength === 0)
391
+ return;
392
+ let pcmData = audio.data;
393
+ const wireRate = audio.metadata?.sampleRateHz;
394
+ if (wireRate !== undefined && wireRate !== this.outputSampleRateHz) {
395
+ const samples = pcm16BytesToSamples(new Uint8Array(pcmData));
396
+ const resampled = pcm16SamplesToBytes(resamplePcm16Streaming(this.streamingResamplers, samples, wireRate, this.outputSampleRateHz));
397
+ const copy = new Uint8Array(resampled.byteLength);
398
+ copy.set(resampled);
399
+ pcmData = copy.buffer;
400
+ }
401
+ this.handleAudioData(pcmData, audio.metadata);
402
+ }
403
+ catch (err) {
404
+ this.emit({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
405
+ }
406
+ }
407
+ scheduleUplink(send) {
408
+ if (!this.uplinkCodecDecided || this.codecNegotiation) {
409
+ this.pendingUplink.push(send);
410
+ return;
411
+ }
412
+ send();
413
+ }
414
+ flushPendingUplink() {
415
+ this.uplinkCodecDecided = true;
416
+ const pending = this.pendingUplink;
417
+ this.pendingUplink = [];
418
+ for (const run of pending)
419
+ run();
420
+ }
421
+ async negotiateWireCodec(audio) {
422
+ if (!audio)
423
+ return;
424
+ this.inputSampleRateHz = audio.inputSampleRateHz;
425
+ const supported = audio.supportedInputCodecs;
426
+ if (!supported?.includes("opus") || this.opusLoadFailed) {
427
+ this.wireCodec = "pcm_s16le";
428
+ this.reportDownlinkCodecCapability();
429
+ return;
430
+ }
431
+ try {
432
+ this.opusCodec = await createBrowserOpusCodec(BROWSER_OPUS_SAMPLE_RATE_HZ);
433
+ this.wireCodec = pickBrowserWireCodec(supported, true);
434
+ }
435
+ catch {
436
+ this.opusLoadFailed = true;
437
+ this.opusCodec = null;
438
+ this.wireCodec = "pcm_s16le";
439
+ }
440
+ this.reportDownlinkCodecCapability();
441
+ }
442
+ reportDownlinkCodecCapability() {
443
+ // The socket can drop during async codec negotiation (opus load) — e.g. a forced
444
+ // reconnect. The capability advert is re-sent on the next `ready` after reconnect,
445
+ // so skip it on a closed socket rather than throwing (which would crash the
446
+ // negotiation promise as an unhandled rejection).
447
+ if (!this.transport.connected)
448
+ return;
449
+ const downlinkEncoding = this.opusCodec ? "opus" : "pcm_s16le";
450
+ this.sendJson({ type: "codec_capability", downlinkEncoding });
451
+ }
452
+ encodeUplinkPcm(bytes, sampleRateHz, options) {
453
+ if (this.wireCodec === "opus" && this.opusCodec) {
454
+ const frames = [];
455
+ let seq = options.sequence;
456
+ const samples = pcm16BytesToSamples(bytes);
457
+ const wireSamples = sampleRateHz === this.opusCodec.sampleRateHz
458
+ ? samples
459
+ : resamplePcm16Streaming(this.streamingResamplers, samples, sampleRateHz, this.opusCodec.sampleRateHz);
460
+ for (const opus of this.opusCodec.encodePcm16Frame(wireSamples, true)) {
461
+ const sequence = this.nextAudioSequence(seq);
462
+ frames.push(encodeBrowserOpusEnvelope(opus, this.opusCodec.sampleRateHz, { ...options, sequence }));
463
+ seq = sequence;
464
+ }
465
+ return frames;
466
+ }
467
+ const sequence = this.nextAudioSequence(options.sequence);
468
+ return [encodeBrowserPcmEnvelope(bytes, sampleRateHz, { ...options, sequence })];
469
+ }
470
+ requireOpenTransport() {
471
+ if (!this.transport.connected) {
472
+ throw new Error("SyrinxBrowserClient transport is not connected");
473
+ }
474
+ return this.transport;
475
+ }
476
+ assertAudioSequenceCanAdvance(sequence) {
477
+ if (!Number.isInteger(sequence) || sequence < 0) {
478
+ throw new Error("audio sequence must be a non-negative integer");
479
+ }
480
+ if (sequence <= this.audioSequence) {
481
+ throw new Error(`audio sequence must increase monotonically: ${String(this.audioSequence)} -> ${String(sequence)}`);
482
+ }
483
+ }
484
+ nextAudioSequence(sequence) {
485
+ if (sequence !== undefined) {
486
+ if (!Number.isInteger(sequence) || sequence < 0)
487
+ throw new Error("audio sequence must be a non-negative integer");
488
+ if (sequence <= this.audioSequence) {
489
+ throw new Error(`audio sequence must increase monotonically: ${String(this.audioSequence)} -> ${String(sequence)}`);
490
+ }
491
+ this.audioSequence = Math.max(this.audioSequence, sequence);
492
+ return sequence;
493
+ }
494
+ this.audioSequence += 1;
495
+ return this.audioSequence;
496
+ }
497
+ handleAudioData(data, metadata) {
498
+ if (this.jitterBuffer) {
499
+ // Use jitter buffer for scheduled playback
500
+ this.jitterBuffer.enqueue(data, metadata?.contextId);
501
+ }
502
+ // Always emit the audio event for applications that want raw access
503
+ this.emit({ type: "audio", data, metadata });
504
+ }
505
+ emit(event) {
506
+ for (const handler of this.handlers) {
507
+ handler(event);
508
+ }
509
+ }
510
+ }
511
+ function buildResumeUrl(baseUrl, sessionId) {
512
+ try {
513
+ const url = new URL(baseUrl);
514
+ url.searchParams.set("sessionId", sessionId);
515
+ return url.toString();
516
+ }
517
+ catch {
518
+ const sep = baseUrl.includes("?") ? "&" : "?";
519
+ return `${baseUrl}${sep}sessionId=${encodeURIComponent(sessionId)}`;
520
+ }
521
+ }
522
+ function readPositiveSampleRate(value) {
523
+ if (!Number.isInteger(value) || value <= 0)
524
+ throw new Error("sampleRateHz must be a positive integer");
525
+ return value;
526
+ }
527
+ function parseStudioMessage(value) {
528
+ if (!isRecord(value))
529
+ throw new Error("Syrinx websocket message must be an object");
530
+ const type = requiredString(value.type, "Syrinx websocket message type");
531
+ if (type === "ready") {
532
+ return {
533
+ type,
534
+ sessionId: optionalString(value.sessionId, "ready.sessionId"),
535
+ resumed: optionalBoolean(value.resumed, "ready.resumed"),
536
+ resumeWindowMs: optionalNumber(value.resumeWindowMs, "ready.resumeWindowMs"),
537
+ audio: parseReadyAudio(value.audio),
538
+ };
539
+ }
540
+ if (type === "speech_started" || type === "speech_ended" || type === "agent_end" || type === "tts_end") {
541
+ return { type, turnId: optionalString(value.turnId, `${type}.turnId`) };
542
+ }
543
+ if (type === "stt_chunk" || type === "stt_output") {
544
+ return {
545
+ type,
546
+ turnId: optionalString(value.turnId, `${type}.turnId`),
547
+ transcript: requiredString(value.transcript, `${type}.transcript`),
548
+ ...(type === "stt_output" ? { confidence: optionalNumber(value.confidence, "stt_output.confidence") } : {}),
549
+ };
550
+ }
551
+ if (type === "agent_chunk") {
552
+ return {
553
+ type,
554
+ turnId: optionalString(value.turnId, "agent_chunk.turnId"),
555
+ text: requiredString(value.text, "agent_chunk.text"),
556
+ };
557
+ }
558
+ if (type === "agent_tool_call") {
559
+ return {
560
+ type,
561
+ turnId: optionalString(value.turnId, "agent_tool_call.turnId"),
562
+ id: optionalString(value.id, "agent_tool_call.id"),
563
+ name: requiredString(value.name, "agent_tool_call.name"),
564
+ args: value.args,
565
+ };
566
+ }
567
+ if (type === "agent_tool_result") {
568
+ return {
569
+ type,
570
+ turnId: optionalString(value.turnId, "agent_tool_result.turnId"),
571
+ id: optionalString(value.id, "agent_tool_result.id"),
572
+ result: value.result,
573
+ };
574
+ }
575
+ if (type === "tool_call_started" ||
576
+ type === "tool_call_delayed" ||
577
+ type === "tool_call_complete" ||
578
+ type === "tool_call_failed") {
579
+ return {
580
+ type,
581
+ turnId: optionalString(value.turnId, `${type}.turnId`),
582
+ toolId: optionalString(value.toolId, `${type}.toolId`),
583
+ toolName: optionalString(value.toolName, `${type}.toolName`),
584
+ ...(type === "tool_call_delayed" ? { afterMs: optionalNumber(value.afterMs, "tool_call_delayed.afterMs") } : {}),
585
+ };
586
+ }
587
+ if (type === "agent_interrupted" || type === "audio_clear") {
588
+ return {
589
+ type,
590
+ turnId: optionalString(value.turnId, `${type}.turnId`),
591
+ reason: optionalString(value.reason, `${type}.reason`),
592
+ };
593
+ }
594
+ if (type === "tts_chunk") {
595
+ return {
596
+ type,
597
+ turnId: optionalString(value.turnId, "tts_chunk.turnId"),
598
+ sequence: requiredNonNegativeInteger(value.sequence, "tts_chunk.sequence"),
599
+ sampleRateHz: requiredPositiveInteger(value.sampleRateHz, "tts_chunk.sampleRateHz"),
600
+ encoding: requiredLiteral(value.encoding, "pcm_s16le", "tts_chunk.encoding"),
601
+ channels: requiredLiteral(value.channels, 1, "tts_chunk.channels"),
602
+ byteLength: requiredNonNegativeInteger(value.byteLength, "tts_chunk.byteLength"),
603
+ durationMs: requiredNonNegativeInteger(value.durationMs, "tts_chunk.durationMs"),
604
+ };
605
+ }
606
+ if (type === "metrics") {
607
+ const endpointingOwner = optionalEndpointingEnum(value.endpointingOwner, ENDPOINTING_OWNERS);
608
+ const endpointingReason = optionalEndpointingEnum(value.endpointingReason, ENDPOINTING_REASONS);
609
+ return {
610
+ type,
611
+ turnId: optionalString(value.turnId, "metrics.turnId"),
612
+ correlationId: optionalString(value.correlationId, "metrics.correlationId"),
613
+ speechEndMs: optionalNumber(value.speechEndMs, "metrics.speechEndMs"),
614
+ textReadyMs: optionalNumber(value.textReadyMs, "metrics.textReadyMs"),
615
+ firstAudioByteMs: optionalNumber(value.firstAudioByteMs, "metrics.firstAudioByteMs"),
616
+ firstAudioPlayedMs: optionalNumber(value.firstAudioPlayedMs, "metrics.firstAudioPlayedMs"),
617
+ lastAudioPlayedMs: optionalNumber(value.lastAudioPlayedMs, "metrics.lastAudioPlayedMs"),
618
+ sttMs: optionalNumber(value.sttMs, "metrics.sttMs"),
619
+ llmTTFTMs: optionalNumber(value.llmTTFTMs, "metrics.llmTTFTMs"),
620
+ ttsTTFBMs: optionalNumber(value.ttsTTFBMs, "metrics.ttsTTFBMs"),
621
+ e2eMs: optionalNumber(value.e2eMs, "metrics.e2eMs"),
622
+ ...(endpointingOwner !== undefined ? { endpointingOwner } : {}),
623
+ ...(endpointingReason !== undefined ? { endpointingReason } : {}),
624
+ };
625
+ }
626
+ if (type === "error") {
627
+ return {
628
+ type,
629
+ component: optionalString(value.component, "error.component"),
630
+ category: optionalString(value.category, "error.category"),
631
+ message: requiredString(value.message, "error.message"),
632
+ };
633
+ }
634
+ if (type === "turn_complete") {
635
+ return {
636
+ type,
637
+ turnId: optionalString(value.turnId, "turn_complete.turnId"),
638
+ transcript: typeof value.transcript === "string" ? value.transcript : "",
639
+ };
640
+ }
641
+ throw new Error(`Unsupported Syrinx websocket message type: ${type}`);
642
+ }
643
+ function parseReadyAudio(value) {
644
+ if (value === undefined)
645
+ return undefined;
646
+ if (!isRecord(value))
647
+ throw new Error("ready.audio must be an object");
648
+ const encoding = value.encoding;
649
+ if (encoding !== "pcm_s16le" && encoding !== "opus") {
650
+ throw new Error("ready.audio.encoding must be pcm_s16le or opus");
651
+ }
652
+ return {
653
+ inputSampleRateHz: requiredPositiveInteger(value.inputSampleRateHz, "ready.audio.inputSampleRateHz"),
654
+ outputSampleRateHz: requiredPositiveInteger(value.outputSampleRateHz, "ready.audio.outputSampleRateHz"),
655
+ encoding,
656
+ supportedInputCodecs: parseSupportedInputCodecs(value.supportedInputCodecs),
657
+ channels: requiredLiteral(value.channels, 1, "ready.audio.channels"),
658
+ binaryEnvelope: value.binaryEnvelope === undefined
659
+ ? undefined
660
+ : requiredLiteral(value.binaryEnvelope, "syrinx.audio.v1", "ready.audio.binaryEnvelope"),
661
+ rawBinaryInput: optionalBoolean(value.rawBinaryInput, "ready.audio.rawBinaryInput"),
662
+ };
663
+ }
664
+ function parseSupportedInputCodecs(value) {
665
+ if (value === undefined)
666
+ return undefined;
667
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
668
+ throw new Error("ready.audio.supportedInputCodecs must be an array of strings");
669
+ }
670
+ return value;
671
+ }
672
+ function isRecord(value) {
673
+ return typeof value === "object" && value !== null && !Array.isArray(value);
674
+ }
675
+ function requiredString(value, name) {
676
+ if (typeof value !== "string" || value.length === 0)
677
+ throw new Error(`${name} must be a non-empty string`);
678
+ return value;
679
+ }
680
+ function rmsFromPcm16Bytes(bytes) {
681
+ const sampleCount = Math.floor(bytes.byteLength / 2);
682
+ if (sampleCount === 0)
683
+ return 0;
684
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, sampleCount);
685
+ let sumSquares = 0;
686
+ for (const sample of samples) {
687
+ const normalized = sample / 32768;
688
+ sumSquares += normalized * normalized;
689
+ }
690
+ return Math.sqrt(sumSquares / sampleCount);
691
+ }
692
+ function rmsFromFloat32(samples) {
693
+ if (samples.length === 0)
694
+ return 0;
695
+ let sumSquares = 0;
696
+ for (const sample of samples) {
697
+ sumSquares += sample * sample;
698
+ }
699
+ return Math.sqrt(sumSquares / samples.length);
700
+ }
701
+ function optionalString(value, name) {
702
+ // An optional field that arrives empty/null carries no value — treat as absent rather than
703
+ // throwing. (Servers may emit e.g. `tts_end.turnId: ""` when a turn has no context id.)
704
+ if (value === undefined || value === null || value === "")
705
+ return undefined;
706
+ return requiredString(value, name);
707
+ }
708
+ function requiredPositiveInteger(value, name) {
709
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
710
+ throw new Error(`${name} must be a positive integer`);
711
+ }
712
+ return value;
713
+ }
714
+ function requiredNonNegativeInteger(value, name) {
715
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
716
+ throw new Error(`${name} must be a non-negative integer`);
717
+ }
718
+ return value;
719
+ }
720
+ function optionalNumber(value, name) {
721
+ if (value === undefined)
722
+ return undefined;
723
+ if (typeof value !== "number" || !Number.isFinite(value))
724
+ throw new Error(`${name} must be a finite number`);
725
+ return value;
726
+ }
727
+ function optionalBoolean(value, name) {
728
+ if (value === undefined)
729
+ return undefined;
730
+ if (typeof value !== "boolean")
731
+ throw new Error(`${name} must be a boolean`);
732
+ return value;
733
+ }
734
+ function optionalEndpointingEnum(value, allowed) {
735
+ // Forward-compatible: an unknown value is treated as absent, not an error, so a
736
+ // newer backend adding an enum member does not break an older client's message.
737
+ return typeof value === "string" && allowed.has(value) ? value : undefined;
738
+ }
739
+ function requiredLiteral(value, expected, name) {
740
+ if (value !== expected)
741
+ throw new Error(`${name} must be ${String(expected)}`);
742
+ return expected;
743
+ }
744
+ //# sourceMappingURL=index.js.map