@kuralle-syrinx/browser-client 4.2.0 → 4.4.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/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @kuralle-syrinx/browser-client
2
+
3
+ The browser SDK for the Syrinx WebSocket voice protocol. Microphone capture,
4
+ opus encoding, jitter-buffered playback, reconnect — and the typed message
5
+ stream a voice agent sends back.
6
+
7
+ **This is what you build your own voice UI on.** [Syrinx Studio](https://github.com/kuralle/syrinx/tree/main/apps/studio)
8
+ is built entirely on it; so is anything you want to embed voice into.
9
+
10
+ ```bash
11
+ npm install @kuralle-syrinx/browser-client
12
+ ```
13
+
14
+ ## Talk to an agent
15
+
16
+ ```ts
17
+ import { SyrinxBrowserClient } from "@kuralle-syrinx/browser-client";
18
+
19
+ const client = new SyrinxBrowserClient({
20
+ url: "ws://localhost:4173/ws",
21
+ audioContext: new AudioContext(),
22
+ jitterBuffer: { targetBufferMs: 100 },
23
+ });
24
+
25
+ client.on((event) => {
26
+ if (event.type === "message" && event.message.type === "stt_output") {
27
+ console.log("you said:", event.message.transcript);
28
+ }
29
+ if (event.type === "message" && event.message.type === "agent_chunk") {
30
+ process.stdout.write(event.message.text);
31
+ }
32
+ });
33
+
34
+ await client.connect();
35
+ ```
36
+
37
+ Send a turn as text instead of speaking — useful for iterating on prompts and
38
+ tools without paying STT/TTS latency or cost:
39
+
40
+ ```ts
41
+ client.sendText("What's the deadline for the CS masters?");
42
+ ```
43
+
44
+ ## What the server sends you
45
+
46
+ `SyrinxStudioMessage` is a discriminated union of roughly twenty variants. The
47
+ ones you will reach for first:
48
+
49
+ | Message | Carries |
50
+ | --- | --- |
51
+ | `ready` | negotiated sample rates, encoding, resume window |
52
+ | `speech_started` / `speech_ended` | turn boundaries |
53
+ | `stt_chunk` / `stt_output` | interim and final transcript (+ confidence) |
54
+ | `agent_chunk` / `agent_end` | the reply, streamed |
55
+ | `agent_tool_call` / `agent_tool_result` | tool use |
56
+ | `tool_call_started` \| `delayed` \| `complete` \| `failed` | the "thinking" cue lifecycle |
57
+ | `agent_interrupted` | barge-in, **with a reason** |
58
+ | `turn_complete`, `tts_chunk`, `tts_end` | turn and audio lifecycle |
59
+ | `metrics` | per-turn latency decomposition |
60
+ | `error` | `component`, `category`, `message` |
61
+
62
+ > **Handle unknown types gracefully.** On Cloudflare the agents SDK also emits
63
+ > `cf_agent_identity` and `cf_agent_mcp_servers`, which are not in this union,
64
+ > and future versions will add more. Ignore what you do not recognise rather
65
+ > than throwing.
66
+
67
+ ## Structured session state — `/record`
68
+
69
+ Folding that stream by hand is tedious and easy to get wrong. The `/record`
70
+ subpath does it for you, and is **dependency-free and DOM-free** so it also runs
71
+ under Node (in a test, a CLI, or CI):
72
+
73
+ ```ts
74
+ import { buildSessionRecord } from "@kuralle-syrinx/browser-client/record";
75
+
76
+ const record = buildSessionRecord(messages.map((m, i) => ({ message: m, atMs: i })));
77
+
78
+ record.turns[0]?.userTranscript; // what the caller said
79
+ record.turns[0]?.agentText; // what the agent replied
80
+ record.turns[0]?.interrupted; // { atMs, reason } when barge-in cut it off
81
+ record.turns[0]?.toolCalls; // merged across all four cue phases
82
+ record.turns[0]?.timings; // optional — see the caveat below
83
+ ```
84
+
85
+ It is a **pure reducer**: same messages in, same record out, no side effects.
86
+ That is what lets you render a UI from a recorded fixture in a test instead of
87
+ needing a live provider.
88
+
89
+ It is also **bounded** — 50 turns and 500 events per turn by default, evicting
90
+ oldest and reporting `droppedTurns` / `droppedEvents` rather than losing data
91
+ silently.
92
+
93
+ > **`timings` is optional and you must handle its absence.** It comes from the
94
+ > `metrics` message, which the Node host emits and the Cloudflare Workers path
95
+ > currently does not. Render a turn without timings rather than assuming they
96
+ > arrive — showing zeroes would read as a zero-latency turn.
97
+
98
+ ## Derived views — `/agent-state` and `/turn-timeline`
99
+
100
+ Both are pure, Node-safe, and derived from the same record.
101
+
102
+ ```ts
103
+ import { deriveAgentState, isStalled } from "@kuralle-syrinx/browser-client/agent-state";
104
+ import { buildTurnTimeline } from "@kuralle-syrinx/browser-client/turn-timeline";
105
+ ```
106
+
107
+ `deriveAgentState` gives you `idle → listening → endpointing → thinking →
108
+ speaking → interrupted` — what a client renders as a listening indicator. Note
109
+ this is *conversational* state; `SessionState` in `@kuralle-syrinx/core` is
110
+ session lifecycle and is a different thing. `isStalled` flags a state held far
111
+ past plausibility, because a thirty-second "thinking" is a hung tool call, not
112
+ patience.
113
+
114
+ `buildTurnTimeline` turns `metrics` into a latency waterfall with the slowest
115
+ segment marked. It also flags replies faster than `FAST_TURN_FLOOR_MS` (700ms) —
116
+ **a sub-second reply is usually the endpointer firing while the caller is still
117
+ speaking**, not a fast agent.
118
+
119
+ ## Session aggregates — `/session-metrics`
120
+
121
+ ```ts
122
+ import { buildSessionMetrics } from "@kuralle-syrinx/browser-client/session-metrics";
123
+ ```
124
+
125
+ Median / p95 / max per stage across a session, plus the ids of turns that
126
+ replied implausibly fast. Percentiles are **nearest-rank**, so every number
127
+ reported is a measurement that actually occurred rather than an interpolation no
128
+ turn ever produced.
129
+
130
+ ## Exports
131
+
132
+ | Path | Contents | Runs under Node? |
133
+ | --- | --- | --- |
134
+ | `.` | client, transport, audio, message types | no — needs `AudioContext` |
135
+ | `./record` | `SessionRecord` assembler | yes |
136
+ | `./agent-state` | conversational state machine | yes |
137
+ | `./turn-timeline` | per-turn latency waterfall | yes |
138
+ | `./session-metrics` | session aggregates | yes |
139
+
140
+ ## Licence
141
+
142
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/browser-client",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Browser client for the Syrinx WebSocket audio protocol — mic capture, opus, jitter buffer, playout progress reporting, reconnect",
6
6
  "keywords": [
@@ -25,14 +25,22 @@
25
25
  "type": "module",
26
26
  "main": "./src/index.ts",
27
27
  "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./record": "./src/session-record.ts",
31
+ "./agent-state": "./src/agent-state.ts",
32
+ "./turn-timeline": "./src/turn-timeline.ts",
33
+ "./session-metrics": "./src/session-metrics.ts",
34
+ "./turn-recorder": "./src/turn-recorder.ts"
35
+ },
28
36
  "dependencies": {
29
37
  "@evan/opus": "1.0.3",
30
- "@kuralle-syrinx/core": "4.2.0"
38
+ "@kuralle-syrinx/core": "4.4.0"
31
39
  },
32
40
  "devDependencies": {
33
41
  "typescript": "^5.7.0",
34
- "vite": "^6.0.0",
35
- "vitest": "^2.1.0"
42
+ "vite": "^6.4.3",
43
+ "vitest": "^3.2.6"
36
44
  },
37
45
  "scripts": {
38
46
  "dev": "vite --port 5173",
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Conversational agent state, derived on the client from messages the server
4
+ // already sends. Distinct from `SessionState` in core, which is session
5
+ // *lifecycle* (uninitialized/ready/closed) — this is what the caller experiences:
6
+ // is it listening to me, thinking, or talking?
7
+ //
8
+ // A pause reads as "broken" without this. It is what a client renders as a
9
+ // listening indicator and what makes a turn timeline legible.
10
+ //
11
+ // Derived rather than sent: every signal already exists on the wire, so this
12
+ // needs no server change and works identically on Node and Workers.
13
+
14
+ import type { SyrinxStudioMessage } from "./index.js";
15
+
16
+ export type AgentState =
17
+ | "idle" // no session activity
18
+ | "listening" // user is speaking
19
+ | "endpointing" // user stopped; deciding whether the turn is over
20
+ | "thinking" // reasoner (and any tools) running
21
+ | "speaking" // assistant audio playing
22
+ | "interrupted"; // barge-in cut the assistant off
23
+
24
+ export interface AgentStateSnapshot {
25
+ readonly state: AgentState;
26
+ /** ms the state has been held, from the caller's clock. Lets a UI flag a stuck state. */
27
+ readonly sinceMs: number;
28
+ readonly turnId?: string;
29
+ }
30
+
31
+ type AnyMessage = { readonly type: string; readonly [k: string]: unknown };
32
+
33
+ /**
34
+ * Fold one message into the state. Pure; the caller supplies the clock so this
35
+ * stays deterministic under test.
36
+ *
37
+ * Ordering note: `speech_started` wins over everything, because a caller talking
38
+ * is the highest-priority fact on the wire — that is also what makes barge-in
39
+ * legible (speaking → listening without waiting for the interrupt message).
40
+ */
41
+ export function nextAgentState(
42
+ prev: AgentStateSnapshot,
43
+ message: SyrinxStudioMessage | AnyMessage,
44
+ atMs: number,
45
+ ): AgentStateSnapshot {
46
+ const type = typeof (message as AnyMessage).type === "string" ? (message as AnyMessage).type : "";
47
+ const turnId = typeof (message as AnyMessage).turnId === "string" ? ((message as AnyMessage).turnId as string) : prev.turnId;
48
+ const to = (state: AgentState): AgentStateSnapshot =>
49
+ state === prev.state ? { ...prev, turnId } : { state, sinceMs: atMs, turnId };
50
+
51
+ switch (type) {
52
+ case "speech_started":
53
+ return to("listening");
54
+ case "speech_ended":
55
+ // Speech stopped, but the turn is not necessarily over — this is exactly
56
+ // the window where a premature endpoint cuts a caller off.
57
+ return to("endpointing");
58
+ case "stt_output":
59
+ case "turn_complete":
60
+ // A final transcript means the turn closed; the reasoner is now running.
61
+ return to("thinking");
62
+ case "agent_tool_call":
63
+ case "tool_call_started":
64
+ case "tool_call_delayed":
65
+ return to("thinking");
66
+ case "agent_chunk":
67
+ // Text is arriving but nothing is audible yet.
68
+ return prev.state === "speaking" ? to("speaking") : to("thinking");
69
+ case "tts_chunk":
70
+ return to("speaking");
71
+ case "agent_interrupted":
72
+ return to("interrupted");
73
+ case "tts_end":
74
+ case "agent_end":
75
+ // Only settle to idle from a talking state; an interrupt stands until the
76
+ // next turn starts, so the UI can show what happened.
77
+ return prev.state === "speaking" ? to("idle") : prev;
78
+ default:
79
+ return prev;
80
+ }
81
+ }
82
+
83
+ export const INITIAL_AGENT_STATE: AgentStateSnapshot = { state: "idle", sinceMs: 0 };
84
+
85
+ /** Fold a whole stream. Equivalent to repeated `nextAgentState`. */
86
+ export function deriveAgentState(
87
+ messages: readonly { readonly message: SyrinxStudioMessage | AnyMessage; readonly atMs: number }[],
88
+ ): AgentStateSnapshot {
89
+ return messages.reduce((s, { message, atMs }) => nextAgentState(s, message, atMs), INITIAL_AGENT_STATE);
90
+ }
91
+
92
+ /**
93
+ * How long a state may plausibly be held before it indicates a stall. A UI should
94
+ * flag rather than hide these — a 30s "thinking" is a hung tool call, not patience.
95
+ */
96
+ export const STATE_STALL_THRESHOLD_MS: Readonly<Record<AgentState, number>> = {
97
+ idle: Number.POSITIVE_INFINITY,
98
+ listening: 60_000,
99
+ endpointing: 5_000,
100
+ thinking: 30_000,
101
+ speaking: 120_000,
102
+ interrupted: 10_000,
103
+ };
104
+
105
+ export function isStalled(snapshot: AgentStateSnapshot, nowMs: number): boolean {
106
+ return nowMs - snapshot.sinceMs > STATE_STALL_THRESHOLD_MS[snapshot.state];
107
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  resamplePcm16Streaming,
29
29
  type StreamingPcm16Resampler,
30
30
  } from "@kuralle-syrinx/core/audio";
31
+ import type { TurnEndOwner, TurnEndReason } from "@kuralle-syrinx/core";
31
32
  import {
32
33
  decodeBrowserAssistantAudio,
33
34
  encodeBrowserAudioEnvelopeFrame,
@@ -68,6 +69,8 @@ export type SyrinxStudioMessage =
68
69
  readonly encoding: "pcm_s16le" | "opus";
69
70
  readonly supportedInputCodecs?: readonly string[];
70
71
  readonly channels: 1;
72
+ /** Node states this; the Workers/DO host does not. Optional for that reason. */
73
+ readonly targetFrameDurationMs?: number;
71
74
  readonly binaryEnvelope?: "syrinx.audio.v1";
72
75
  readonly rawBinaryInput?: boolean;
73
76
  };
@@ -114,6 +117,10 @@ export type SyrinxStudioMessage =
114
117
  readonly llmTTFTMs?: number;
115
118
  readonly ttsTTFBMs?: number;
116
119
  readonly e2eMs?: number;
120
+ /** Which owner decided the turn ended. Omitted when the backend did not say. */
121
+ readonly endpointingOwner?: TurnEndOwner;
122
+ /** Why the turn ended. Omitted when the backend did not say. */
123
+ readonly endpointingReason?: TurnEndReason;
117
124
  }
118
125
  | { readonly type: "error"; readonly component?: string; readonly category?: string; readonly message: string };
119
126
 
@@ -187,6 +194,11 @@ const RECONNECT_MIN_STABLE_MS = 5_000;
187
194
  const RECONNECT_MAX_QUICK_FAILURES = 3;
188
195
  const KEEPALIVE_INTERVAL_MS = 10_000;
189
196
 
197
+ // Endpointing decision values carried on the `metrics` message. Forward-compatible:
198
+ // an unknown value (a newer backend) is ignored rather than dropping the message.
199
+ const ENDPOINTING_OWNERS = new Set<string>(["provider_stt", "smart_turn", "timer", "text"]);
200
+ const ENDPOINTING_REASONS = new Set<string>(["end_of_speech", "force_finalized", "typed"]);
201
+
190
202
  export class SyrinxBrowserClient {
191
203
  private readonly transport: ClientTransport;
192
204
  private readonly handlers = new Set<SyrinxBrowserClientHandler>();
@@ -535,7 +547,15 @@ export class SyrinxBrowserClient {
535
547
  private handleJsonMessage(data: unknown): void {
536
548
  if (typeof data !== "string") return;
537
549
  try {
538
- const message = parseStudioMessage(JSON.parse(data) as unknown);
550
+ const parsed = JSON.parse(data) as unknown;
551
+ // Cloudflare Agents SDK control frames (cf_agent_state, cf_agent_mcp_servers, …) are
552
+ // broadcast by the Agent base class on the same socket a cf-agents `withVoice` worker
553
+ // uses for voice. They are not part of the Syrinx voice protocol — ignore them rather
554
+ // than throwing (which surfaced a spurious `error` event → a false "Error" badge).
555
+ if (isRecord(parsed) && typeof parsed["type"] === "string" && parsed["type"].startsWith("cf_agent")) {
556
+ return;
557
+ }
558
+ const message = parseStudioMessage(parsed);
539
559
  if (message.type === "ready") {
540
560
  if (message.sessionId !== undefined) this.currentSessionId = message.sessionId;
541
561
  if (message.audio?.outputSampleRateHz && message.audio.outputSampleRateHz !== this.outputSampleRateHz) {
@@ -801,6 +821,14 @@ function parseStudioMessage(value: unknown): SyrinxStudioMessage {
801
821
  };
802
822
  }
803
823
  if (type === "metrics") {
824
+ const endpointingOwner = optionalEndpointingEnum<TurnEndOwner>(
825
+ value.endpointingOwner,
826
+ ENDPOINTING_OWNERS,
827
+ );
828
+ const endpointingReason = optionalEndpointingEnum<TurnEndReason>(
829
+ value.endpointingReason,
830
+ ENDPOINTING_REASONS,
831
+ );
804
832
  return {
805
833
  type,
806
834
  turnId: optionalString(value.turnId, "metrics.turnId"),
@@ -814,6 +842,8 @@ function parseStudioMessage(value: unknown): SyrinxStudioMessage {
814
842
  llmTTFTMs: optionalNumber(value.llmTTFTMs, "metrics.llmTTFTMs"),
815
843
  ttsTTFBMs: optionalNumber(value.ttsTTFBMs, "metrics.ttsTTFBMs"),
816
844
  e2eMs: optionalNumber(value.e2eMs, "metrics.e2eMs"),
845
+ ...(endpointingOwner !== undefined ? { endpointingOwner } : {}),
846
+ ...(endpointingReason !== undefined ? { endpointingReason } : {}),
817
847
  };
818
848
  }
819
849
  if (type === "error") {
@@ -925,6 +955,12 @@ function optionalBoolean(value: unknown, name: string): boolean | undefined {
925
955
  return value;
926
956
  }
927
957
 
958
+ function optionalEndpointingEnum<T extends string>(value: unknown, allowed: ReadonlySet<string>): T | undefined {
959
+ // Forward-compatible: an unknown value is treated as absent, not an error, so a
960
+ // newer backend adding an enum member does not break an older client's message.
961
+ return typeof value === "string" && allowed.has(value) ? (value as T) : undefined;
962
+ }
963
+
928
964
  function requiredLiteral<T extends string | number>(value: unknown, expected: T, name: string): T {
929
965
  if (value !== expected) throw new Error(`${name} must be ${String(expected)}`);
930
966
  return expected;
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Session-level latency aggregates. A one-off spike and a consistent regression
4
+ // look identical turn by turn; you need the distribution to tell them apart.
5
+ //
6
+ // Deliberately reports a FLOOR as well as a ceiling. A sub-second reply is the
7
+ // number every voice framework optimises for, and in a turn-taking system it is
8
+ // usually evidence the endpointer fired while the caller was still speaking.
9
+ // Celebrating it is how "fast" agents ship that interrupt people.
10
+
11
+ import type { TurnRecord, TurnTimings } from "./session-record.js";
12
+ import { FAST_TURN_FLOOR_MS } from "./turn-timeline.js";
13
+
14
+ export interface StageStats {
15
+ readonly stage: string;
16
+ readonly label: string;
17
+ readonly count: number;
18
+ readonly medianMs: number;
19
+ readonly p95Ms: number;
20
+ readonly maxMs: number;
21
+ }
22
+
23
+ export interface SessionMetrics {
24
+ readonly turnCount: number;
25
+ /** Turns that carried timings. The Workers path sends none, so this can be 0. */
26
+ readonly measuredTurnCount: number;
27
+ readonly stages: readonly StageStats[];
28
+ /** Turns whose end-to-end fell below the floor — probable premature endpointing. */
29
+ readonly suspiciouslyFastTurnIds: readonly string[];
30
+ readonly floorMs: number;
31
+ /** True when nothing could be measured, so the UI says why instead of showing zeroes. */
32
+ readonly unavailable: boolean;
33
+ }
34
+
35
+ const STAGES: readonly { readonly key: keyof TurnTimings; readonly stage: string; readonly label: string }[] = [
36
+ { key: "sttMs", stage: "stt", label: "Hearing you" },
37
+ { key: "llmTTFTMs", stage: "llm", label: "Thinking (to first word)" },
38
+ { key: "ttsTTFBMs", stage: "tts", label: "Voice (to first audio)" },
39
+ { key: "e2eMs", stage: "e2e", label: "End to end" },
40
+ ];
41
+
42
+ /**
43
+ * Nearest-rank percentile. Chosen over interpolation deliberately: every value
44
+ * reported is a real measurement that occurred, so a p95 can be traced back to
45
+ * an actual turn rather than being a number no turn ever had.
46
+ */
47
+ export function percentile(sortedAsc: readonly number[], p: number): number {
48
+ if (sortedAsc.length === 0) return 0;
49
+ const rank = Math.ceil((p / 100) * sortedAsc.length);
50
+ const idx = Math.min(sortedAsc.length - 1, Math.max(0, rank - 1));
51
+ return sortedAsc[idx] ?? 0;
52
+ }
53
+
54
+ export function buildSessionMetrics(
55
+ turns: readonly TurnRecord[],
56
+ floorMs: number = FAST_TURN_FLOOR_MS,
57
+ ): SessionMetrics {
58
+ const measured = turns.filter((t) => t.timings !== undefined);
59
+
60
+ const stages = STAGES.map(({ key, stage, label }) => {
61
+ const values = measured
62
+ .map((t) => t.timings?.[key])
63
+ .filter((v): v is number => typeof v === "number")
64
+ .sort((a, b) => a - b);
65
+ return {
66
+ stage,
67
+ label,
68
+ count: values.length,
69
+ medianMs: percentile(values, 50),
70
+ p95Ms: percentile(values, 95),
71
+ maxMs: values.length > 0 ? (values[values.length - 1] ?? 0) : 0,
72
+ };
73
+ }).filter((s) => s.count > 0);
74
+
75
+ const suspiciouslyFastTurnIds = measured
76
+ .filter((t) => {
77
+ const e2e = t.timings?.e2eMs;
78
+ return typeof e2e === "number" && e2e > 0 && e2e < floorMs;
79
+ })
80
+ .map((t) => t.turnId);
81
+
82
+ return {
83
+ turnCount: turns.length,
84
+ measuredTurnCount: measured.length,
85
+ stages,
86
+ suspiciouslyFastTurnIds,
87
+ floorMs,
88
+ unavailable: measured.length === 0,
89
+ };
90
+ }