@kuralle-syrinx/browser-client 3.1.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 +2 -2
- package/src/audio.ts +35 -0
- package/src/index.test.ts +33 -0
- package/src/index.ts +62 -0
- package/src/jitter-buffer.test.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/browser-client",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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);
|