@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
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ deriveAgentState,
7
+ INITIAL_AGENT_STATE,
8
+ isStalled,
9
+ nextAgentState,
10
+ type AgentState,
11
+ } from "./agent-state.js";
12
+
13
+ const at = (message: unknown, atMs: number) => ({ message: message as never, atMs });
14
+
15
+ /** Fold a stream and return just the state, for readability. */
16
+ const stateAfter = (msgs: readonly { message: never; atMs: number }[]): AgentState =>
17
+ deriveAgentState(msgs).state;
18
+
19
+ describe("agent state — the full turn cycle", () => {
20
+ it("walks idle → listening → endpointing → thinking → speaking → idle", () => {
21
+ const seen: AgentState[] = [];
22
+ let s = INITIAL_AGENT_STATE;
23
+ seen.push(s.state);
24
+ for (const [m, t] of [
25
+ [{ type: "speech_started", turnId: "t1" }, 0],
26
+ [{ type: "speech_ended", turnId: "t1" }, 1000],
27
+ [{ type: "stt_output", turnId: "t1", transcript: "hi" }, 1200],
28
+ [{ type: "tts_chunk", turnId: "t1", byteLength: 100 }, 2000],
29
+ [{ type: "tts_end", turnId: "t1" }, 4000],
30
+ ] as const) {
31
+ s = nextAgentState(s, m as never, t as number);
32
+ seen.push(s.state);
33
+ }
34
+ expect(seen).toEqual(["idle", "listening", "endpointing", "thinking", "speaking", "idle"]);
35
+ });
36
+
37
+ it("tracks the turnId through the cycle", () => {
38
+ const s = deriveAgentState([
39
+ at({ type: "speech_started", turnId: "t7" }, 0),
40
+ at({ type: "tts_chunk", turnId: "t7" }, 10),
41
+ ]);
42
+ expect(s.turnId).toBe("t7");
43
+ });
44
+ });
45
+
46
+ describe("agent state — interruption", () => {
47
+ it("goes to interrupted from speaking", () => {
48
+ expect(stateAfter([
49
+ at({ type: "tts_chunk", turnId: "t1" }, 0),
50
+ at({ type: "agent_interrupted", turnId: "t1", reason: "user_speech" }, 500),
51
+ ])).toBe("interrupted");
52
+ });
53
+
54
+ it("holds interrupted through tts_end rather than settling to idle", () => {
55
+ // The UI must be able to show that the turn was cut off, not that it ended normally.
56
+ expect(stateAfter([
57
+ at({ type: "tts_chunk", turnId: "t1" }, 0),
58
+ at({ type: "agent_interrupted", turnId: "t1" }, 500),
59
+ at({ type: "tts_end", turnId: "t1" }, 510),
60
+ ])).toBe("interrupted");
61
+ });
62
+
63
+ it("leaves interrupted when the next turn starts", () => {
64
+ expect(stateAfter([
65
+ at({ type: "tts_chunk", turnId: "t1" }, 0),
66
+ at({ type: "agent_interrupted", turnId: "t1" }, 500),
67
+ at({ type: "speech_started", turnId: "t2" }, 600),
68
+ ])).toBe("listening");
69
+ });
70
+
71
+ it("shows listening the moment the caller speaks over the agent", () => {
72
+ // Barge-in is legible from speech_started alone — no wait for the interrupt message.
73
+ expect(stateAfter([
74
+ at({ type: "tts_chunk", turnId: "t1" }, 0),
75
+ at({ type: "speech_started", turnId: "t2" }, 100),
76
+ ])).toBe("listening");
77
+ });
78
+ });
79
+
80
+ describe("agent state — tools", () => {
81
+ it("stays thinking across a tool call", () => {
82
+ expect(stateAfter([
83
+ at({ type: "stt_output", turnId: "t1", transcript: "q" }, 0),
84
+ at({ type: "agent_tool_call", turnId: "t1", id: "c1", name: "lookup" }, 10),
85
+ at({ type: "tool_call_delayed", turnId: "t1", toolId: "c1", afterMs: 800 }, 900),
86
+ ])).toBe("thinking");
87
+ });
88
+
89
+ it("agent_chunk before audio is thinking, not speaking", () => {
90
+ // Text has arrived but nothing is audible — the caller still hears silence.
91
+ expect(stateAfter([
92
+ at({ type: "stt_output", turnId: "t1", transcript: "q" }, 0),
93
+ at({ type: "agent_chunk", turnId: "t1", text: "The" }, 10),
94
+ ])).toBe("thinking");
95
+ });
96
+
97
+ it("agent_chunk after audio starts keeps it speaking", () => {
98
+ expect(stateAfter([
99
+ at({ type: "tts_chunk", turnId: "t1" }, 0),
100
+ at({ type: "agent_chunk", turnId: "t1", text: "more" }, 10),
101
+ ])).toBe("speaking");
102
+ });
103
+ });
104
+
105
+ describe("agent state — robustness", () => {
106
+ it("ignores unknown message types", () => {
107
+ // Proven on Cloudflare: the agents SDK sends these before `ready`.
108
+ expect(stateAfter([
109
+ at({ type: "cf_agent_identity" }, 0),
110
+ at({ type: "cf_agent_mcp_servers" }, 1),
111
+ at({ type: "some_future_message", turnId: "t1" }, 2),
112
+ ])).toBe("idle");
113
+ });
114
+
115
+ it("does not reset sinceMs when a message re-asserts the same state", () => {
116
+ const a = nextAgentState(INITIAL_AGENT_STATE, { type: "tts_chunk", turnId: "t1" } as never, 100);
117
+ const b = nextAgentState(a, { type: "tts_chunk", turnId: "t1" } as never, 900);
118
+ expect(b.state).toBe("speaking");
119
+ expect(b.sinceMs).toBe(100); // held since the first chunk, not the latest
120
+ });
121
+
122
+ it("is pure — the previous snapshot is unchanged", () => {
123
+ const before = INITIAL_AGENT_STATE;
124
+ nextAgentState(before, { type: "speech_started" } as never, 5);
125
+ expect(before).toEqual({ state: "idle", sinceMs: 0 });
126
+ });
127
+ });
128
+
129
+ describe("agent state — stall detection", () => {
130
+ it("flags an endpointing state held too long", () => {
131
+ const s = { state: "endpointing" as const, sinceMs: 0 };
132
+ expect(isStalled(s, 4_000)).toBe(false);
133
+ expect(isStalled(s, 6_000)).toBe(true); // 5s threshold — a stuck endpointer
134
+ });
135
+
136
+ it("never flags idle", () => {
137
+ expect(isStalled({ state: "idle", sinceMs: 0 }, 10_000_000)).toBe(false);
138
+ });
139
+
140
+ it("flags a hung tool call as a stalled thinking state", () => {
141
+ expect(isStalled({ state: "thinking", sinceMs: 0 }, 31_000)).toBe(true);
142
+ });
143
+ });
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
5
+ import {
6
+ decodeBrowserAssistantAudio,
7
+ encodeBrowserAudioEnvelopeFrame,
8
+ encodeBrowserAudioFrame,
9
+ float32ToPcm16,
10
+ pcm16FrameSampleCount,
11
+ resampleFloat32Linear,
12
+ } from "./audio.js";
13
+
14
+ describe("browser audio utilities", () => {
15
+ it("resamples 48 kHz microphone input to 16 kHz PCM without trusting AudioContext rate", () => {
16
+ const input = new Float32Array(480);
17
+ for (let i = 0; i < input.length; i += 1) input[i] = i / input.length;
18
+
19
+ const output = resampleFloat32Linear(input, {
20
+ fromSampleRateHz: 48000,
21
+ toSampleRateHz: 16000,
22
+ });
23
+
24
+ expect(output.length).toBe(160);
25
+ expect(output[0]).toBeCloseTo(0);
26
+ expect(output[1]).toBeCloseTo(input[3]!);
27
+ expect(output.at(-1)).toBeCloseTo(input[477]!);
28
+ });
29
+
30
+ it("resamples 44.1 kHz microphone input to 16 kHz with stable frame sizing", () => {
31
+ const input = new Float32Array(441);
32
+ input.fill(0.25);
33
+
34
+ const output = resampleFloat32Linear(input, {
35
+ fromSampleRateHz: 44100,
36
+ toSampleRateHz: 16000,
37
+ });
38
+
39
+ expect(output.length).toBe(160);
40
+ expect(pcm16FrameSampleCount(16000)).toBe(320);
41
+ expect(output.every((sample) => Math.abs(sample - 0.25) < 0.0001)).toBe(true);
42
+ });
43
+
44
+ it("encodes browser Float32 audio as turn-scoped 16 kHz PCM16 JSON frames", () => {
45
+ const frame = encodeBrowserAudioFrame(new Float32Array([-2, -1, 0, 0.5, 1, 2]), {
46
+ fromSampleRateHz: 48000,
47
+ toSampleRateHz: 16000,
48
+ contextId: "review-turn",
49
+ });
50
+ const bytes = Buffer.from(frame.audio, "base64");
51
+ const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
52
+
53
+ expect(frame).toMatchObject({
54
+ type: "audio",
55
+ contextId: "review-turn",
56
+ sequence: undefined,
57
+ sampleRateHz: 16000,
58
+ });
59
+ expect(Array.from(pcm)).toEqual([-32768, 16384]);
60
+ });
61
+
62
+ it("preserves sequence metadata on encoded browser JSON audio frames", () => {
63
+ const frame = encodeBrowserAudioFrame(new Float32Array([0, 0.5, 1]), {
64
+ fromSampleRateHz: 48000,
65
+ toSampleRateHz: 16000,
66
+ contextId: "review-turn",
67
+ sequence: 17,
68
+ });
69
+
70
+ expect(frame).toMatchObject({
71
+ type: "audio",
72
+ contextId: "review-turn",
73
+ sampleRateHz: 16000,
74
+ sequence: 17,
75
+ });
76
+ });
77
+
78
+ it("encodes browser Float32 audio as turn-scoped binary envelope frames", () => {
79
+ const frame = encodeBrowserAudioEnvelopeFrame(new Float32Array([-2, -1, 0, 0.5, 1, 2]), {
80
+ fromSampleRateHz: 48000,
81
+ toSampleRateHz: 16000,
82
+ contextId: "review-turn",
83
+ sequence: 9,
84
+ });
85
+
86
+ const decoded = decodeBrowserAssistantAudio(frame);
87
+ const bytes = new Uint8Array(decoded.data);
88
+ const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
89
+
90
+ expect(decoded.metadata).toMatchObject({
91
+ type: "audio",
92
+ contextId: "review-turn",
93
+ sampleRateHz: 16000,
94
+ sequence: 9,
95
+ encoding: "pcm_s16le",
96
+ channels: 1,
97
+ byteLength: 4,
98
+ });
99
+ expect(Array.from(pcm)).toEqual([-32768, 16384]);
100
+ });
101
+
102
+ it("clamps Float32 samples before PCM16 conversion", () => {
103
+ expect(Array.from(float32ToPcm16(new Float32Array([-1.5, -1, 0, 1, 1.5])))).toEqual([
104
+ -32768,
105
+ -32768,
106
+ 0,
107
+ 32767,
108
+ 32767,
109
+ ]);
110
+ });
111
+
112
+ it("decodes enveloped assistant audio before playback", () => {
113
+ const encoded = encodeSyrinxAudioEnvelope({
114
+ type: "audio",
115
+ contextId: "turn-tts",
116
+ sampleRateHz: 16000,
117
+ sequence: 2,
118
+ byteLength: 4,
119
+ }, new Uint8Array([1, 2, 3, 4]));
120
+
121
+ const decoded = decodeBrowserAssistantAudio(encoded);
122
+
123
+ expect(new Uint8Array(decoded.data)).toEqual(new Uint8Array([1, 2, 3, 4]));
124
+ expect(decoded.metadata).toMatchObject({
125
+ contextId: "turn-tts",
126
+ sampleRateHz: 16000,
127
+ sequence: 2,
128
+ });
129
+ });
130
+ });