@kuralle-syrinx/browser-client 3.0.0 → 4.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/browser-client",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "types": "./src/index.ts",
9
9
  "dependencies": {
10
10
  "@evan/opus": "1.0.3",
11
- "@kuralle-syrinx/core": "3.0.0"
11
+ "@kuralle-syrinx/core": "4.0.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "typescript": "^5.7.0",
package/src/audio.ts CHANGED
@@ -172,12 +172,37 @@ export class AudioJitterBuffer {
172
172
  private readonly sampleRateHz: number;
173
173
  private readonly contextIds = new Set<string>();
174
174
  private lastEnqueuedContextId: string | null = null;
175
+ // Per-context playout window on the AudioContext clock: the scheduledTime of the
176
+ // first frame (start) and the running end (start + all scheduled durations). Lets
177
+ // us report how many ms of a turn's audio the user has ACTUALLY heard — the
178
+ // authoritative signal for barge-in context truncation (B2).
179
+ private readonly playoutWindow = new Map<string, { startTime: number; endTime: number }>();
175
180
 
176
181
  /** True while assistant audio is scheduled or playing on the playout clock (not the generation clock). */
177
182
  get isPlayingOut(): boolean {
178
183
  return this.scheduledFrames.size > 0;
179
184
  }
180
185
 
186
+ /**
187
+ * Milliseconds of `contextId`'s audio that have actually played out of the
188
+ * speaker so far, clamped to what has been scheduled. This is the played-out
189
+ * clock the server needs to truncate assistant history to what was heard.
190
+ */
191
+ playedOutMs(contextId: string): number {
192
+ const window = this.playoutWindow.get(contextId);
193
+ if (!window) return 0;
194
+ const elapsedMs = (this.context.currentTime - window.startTime) * 1000;
195
+ const scheduledMs = (window.endTime - window.startTime) * 1000;
196
+ return Math.max(0, Math.min(elapsedMs, scheduledMs));
197
+ }
198
+
199
+ /** True once all of `contextId`'s scheduled audio has finished playing. */
200
+ isPlayoutComplete(contextId: string): boolean {
201
+ const window = this.playoutWindow.get(contextId);
202
+ if (!window) return false;
203
+ return this.context.currentTime >= window.endTime;
204
+ }
205
+
181
206
  /** ContextId of the most recently scheduled assistant audio while any audio is still playing out. */
182
207
  get activeContextId(): string | null {
183
208
  return this.scheduledFrames.size > 0 ? this.lastEnqueuedContextId : null;
@@ -226,6 +251,15 @@ export class AudioJitterBuffer {
226
251
  if (contextId) {
227
252
  this.contextIds.add(contextId);
228
253
  this.lastEnqueuedContextId = contextId;
254
+ const window = this.playoutWindow.get(contextId);
255
+ if (window) {
256
+ window.endTime = this.nextScheduledTime + audioBuffer.duration;
257
+ } else {
258
+ this.playoutWindow.set(contextId, {
259
+ startTime: this.nextScheduledTime,
260
+ endTime: this.nextScheduledTime + audioBuffer.duration,
261
+ });
262
+ }
229
263
  }
230
264
 
231
265
  // Schedule the audio
@@ -263,6 +297,7 @@ export class AudioJitterBuffer {
263
297
  }
264
298
  }
265
299
  this.contextIds.delete(contextId);
300
+ this.playoutWindow.delete(contextId);
266
301
  if (this.lastEnqueuedContextId === contextId) this.lastEnqueuedContextId = null;
267
302
  this.recomputeNextScheduledTime();
268
303
  } else {
package/src/index.test.ts CHANGED
@@ -672,6 +672,39 @@ describe("SyrinxBrowserClient — metrics", () => {
672
672
  });
673
673
  });
674
674
 
675
+ describe("SyrinxBrowserClient — tool-call cues (G3/WBS-3)", () => {
676
+ beforeEach(() => {
677
+ sockets = [];
678
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
679
+ });
680
+
681
+ afterEach(() => {
682
+ globalThis.WebSocket = originalWebSocket;
683
+ });
684
+
685
+ it("surfaces the four typed tool_call_* lifecycle messages", () => {
686
+ const client = makeClient();
687
+ const events = collectEvents(client);
688
+ client.connect();
689
+ const socket = sockets[0]!;
690
+ socket.dispatch("open", {});
691
+
692
+ const frames = [
693
+ { type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
694
+ { type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 2000 },
695
+ { type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
696
+ { type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
697
+ ];
698
+ for (const frame of frames) socket.dispatch("message", { data: JSON.stringify(frame) });
699
+
700
+ const cueMessages = events
701
+ .filter((event): event is Extract<SyrinxBrowserClientEvent, { type: "message" }> => event.type === "message")
702
+ .map((event) => event.message)
703
+ .filter((message) => message.type.startsWith("tool_call_"));
704
+ expect(cueMessages).toMatchObject(frames);
705
+ });
706
+ });
707
+
675
708
  describe("local barge-in (client_interrupt)", () => {
676
709
  beforeEach(() => {
677
710
  sockets = [];
package/src/index.ts CHANGED
@@ -79,6 +79,13 @@ export type SyrinxStudioMessage =
79
79
  | { readonly type: "agent_chunk"; readonly turnId?: string; readonly text: string }
80
80
  | { readonly type: "agent_tool_call"; readonly turnId?: string; readonly id?: string; readonly name: string; readonly args?: unknown }
81
81
  | { readonly type: "agent_tool_result"; readonly turnId?: string; readonly id?: string; readonly result?: unknown }
82
+ // G3 typed preamble/filler lifecycle (RFC bimodel-delegate-seam): the standard
83
+ // "thinking" cue. started = the front invoked a tool (arm an earcon/indicator);
84
+ // delayed = still working after afterMs; complete/failed = stop the cue.
85
+ | { readonly type: "tool_call_started"; readonly turnId?: string; readonly toolId?: string; readonly toolName?: string }
86
+ | { readonly type: "tool_call_delayed"; readonly turnId?: string; readonly toolId?: string; readonly toolName?: string; readonly afterMs?: number }
87
+ | { readonly type: "tool_call_complete"; readonly turnId?: string; readonly toolId?: string; readonly toolName?: string }
88
+ | { readonly type: "tool_call_failed"; readonly turnId?: string; readonly toolId?: string; readonly toolName?: string }
82
89
  | { readonly type: "agent_end"; readonly turnId?: string }
83
90
  | { readonly type: "agent_interrupted"; readonly turnId?: string; readonly reason?: string }
84
91
  | { readonly type: "audio_clear"; readonly turnId?: string; readonly reason?: string }
@@ -191,6 +198,8 @@ export class SyrinxBrowserClient {
191
198
  private openedAt = 0;
192
199
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
193
200
  private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
201
+ private playoutProgressTimer: ReturnType<typeof setInterval> | null = null;
202
+ private lastProgressContextId: string | null = null;
194
203
  private jitterBuffer: AudioJitterBuffer | null = null;
195
204
  private outputSampleRateHz = 16000;
196
205
  private wireCodec: BrowserWireCodec = "pcm_s16le";
@@ -372,10 +381,12 @@ export class SyrinxBrowserClient {
372
381
  this.emit({ type: "open" });
373
382
  }
374
383
  this.startKeepalive();
384
+ this.startPlayoutProgressReporting();
375
385
  }
376
386
 
377
387
  private handleTransportClose(code: number, reason: string): void {
378
388
  this.stopKeepalive();
389
+ this.stopPlayoutProgressReporting();
379
390
  if (!this.cleanClose) {
380
391
  this.jitterBuffer?.clear();
381
392
  }
@@ -484,6 +495,43 @@ export class SyrinxBrowserClient {
484
495
  }
485
496
  }
486
497
 
498
+ // The browser is the playout clock on the WebSocket media path: report how many
499
+ // ms of the current assistant turn have actually reached the speaker, so the
500
+ // server can truncate conversation history to what the user HEARD on barge-in
501
+ // (B2) instead of falling back to a stale 0. ~200ms cadence matches the server
502
+ // emitter's throttle; a final complete:true is sent when a turn finishes playing.
503
+ private startPlayoutProgressReporting(): void {
504
+ this.stopPlayoutProgressReporting();
505
+ if (!this.jitterBuffer) return;
506
+ this.playoutProgressTimer = setInterval(() => {
507
+ const jitter = this.jitterBuffer;
508
+ if (!jitter || !this.transport.connected) return;
509
+ const contextId = jitter.activeContextId ?? this.lastProgressContextId;
510
+ if (!contextId) return;
511
+ const complete = jitter.isPlayoutComplete(contextId);
512
+ try {
513
+ this.sendJson({
514
+ type: "playout_progress",
515
+ contextId,
516
+ playedOutMs: Math.round(jitter.playedOutMs(contextId)),
517
+ complete,
518
+ });
519
+ } catch {
520
+ this.stopPlayoutProgressReporting();
521
+ return;
522
+ }
523
+ this.lastProgressContextId = complete ? null : contextId;
524
+ }, 200);
525
+ }
526
+
527
+ private stopPlayoutProgressReporting(): void {
528
+ if (this.playoutProgressTimer !== null) {
529
+ clearInterval(this.playoutProgressTimer);
530
+ this.playoutProgressTimer = null;
531
+ }
532
+ this.lastProgressContextId = null;
533
+ }
534
+
487
535
  private handleJsonMessage(data: unknown): void {
488
536
  if (typeof data !== "string") return;
489
537
  try {
@@ -719,6 +767,20 @@ function parseStudioMessage(value: unknown): SyrinxStudioMessage {
719
767
  result: value.result,
720
768
  };
721
769
  }
770
+ if (
771
+ type === "tool_call_started" ||
772
+ type === "tool_call_delayed" ||
773
+ type === "tool_call_complete" ||
774
+ type === "tool_call_failed"
775
+ ) {
776
+ return {
777
+ type,
778
+ turnId: optionalString(value.turnId, `${type}.turnId`),
779
+ toolId: optionalString(value.toolId, `${type}.toolId`),
780
+ toolName: optionalString(value.toolName, `${type}.toolName`),
781
+ ...(type === "tool_call_delayed" ? { afterMs: optionalNumber(value.afterMs, "tool_call_delayed.afterMs") } : {}),
782
+ };
783
+ }
722
784
  if (type === "agent_interrupted" || type === "audio_clear") {
723
785
  return {
724
786
  type,
@@ -115,6 +115,29 @@ describe("AudioJitterBuffer", () => {
115
115
  expect(jitterBuffer.activeContextIds).toHaveLength(2);
116
116
  });
117
117
 
118
+ it("reports played-out ms clamped to the scheduled window (heard clock)", () => {
119
+ const frameData = new Int16Array(320); // 20ms at 16kHz
120
+ frameData.fill(1000);
121
+ mockContext.currentTime = 1.0;
122
+ jitterBuffer.enqueue(frameData.buffer, "ctx"); // scheduled at 1.1, ends 1.12
123
+
124
+ // Before the frame starts playing → nothing heard yet.
125
+ expect(jitterBuffer.playedOutMs("ctx")).toBe(0);
126
+ expect(jitterBuffer.isPlayoutComplete("ctx")).toBe(false);
127
+
128
+ // Halfway through the 20ms frame → ~10ms heard.
129
+ mockContext.currentTime = 1.11;
130
+ expect(jitterBuffer.playedOutMs("ctx")).toBeCloseTo(10, 0);
131
+
132
+ // Past the end → clamped to the full 20ms, and complete.
133
+ mockContext.currentTime = 1.2;
134
+ expect(jitterBuffer.playedOutMs("ctx")).toBeCloseTo(20, 0);
135
+ expect(jitterBuffer.isPlayoutComplete("ctx")).toBe(true);
136
+
137
+ // Unknown context reports 0.
138
+ expect(jitterBuffer.playedOutMs("nope")).toBe(0);
139
+ });
140
+
118
141
  it("clears specific context frames on context-specific clear", () => {
119
142
  const frameData = new Int16Array(320);
120
143
  frameData.fill(1000);