@kuralle-syrinx/server-websocket 4.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Node WebSocket voice host for Syrinx — browser transport plus Twilio/Telnyx/SmartPBX telephony adapters, background audio, admission control",
6
6
  "keywords": [
@@ -35,8 +35,8 @@
35
35
  "dependencies": {
36
36
  "@evan/opus": "1.0.3",
37
37
  "ws": "^8.21.0",
38
- "@kuralle-syrinx/core": "4.3.0",
39
- "@kuralle-syrinx/ws": "4.3.0"
38
+ "@kuralle-syrinx/core": "4.4.0",
39
+ "@kuralle-syrinx/ws": "4.4.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^22.0.0",
package/src/edge.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  resampleAudioBytes,
38
38
  socketDataToBytes,
39
39
  } from "./inbound-audio.js";
40
+ import { TurnMetricsTracker, type TurnTimestampState } from "./turn-metrics.js";
40
41
 
41
42
  /**
42
43
  * Sink for per-conversation audio recording. The edge taps inbound caller audio
@@ -287,6 +288,7 @@ export async function runVoiceEdgeWebSocketConnection(
287
288
  socket,
288
289
  disposers,
289
290
  outputSampleRateHz,
291
+ leased.managed.turnMetricsTurns,
290
292
  options.recorder,
291
293
  options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined,
292
294
  );
@@ -372,10 +374,24 @@ function wireEdgeSessionEvents(
372
374
  socket: ManagedSocket,
373
375
  disposers: Array<() => void>,
374
376
  outputSampleRateHz: number,
377
+ turnMetricsTurns: Map<string, TurnTimestampState>,
375
378
  recorder?: EdgeRecorder,
376
379
  backgroundAudio?: BackgroundAudioMixer,
377
380
  ): void {
378
381
  if (backgroundAudio) wireBackgroundAudio(session, backgroundAudio);
382
+
383
+ // Same per-turn latency decomposition the Node websocket path emits (turn-metrics.ts) —
384
+ // the browser/edge protocol is shared, so the `metrics` message must be too. Unlike
385
+ // Node, this transport is client-paced (no server-side pacer guarantees a
386
+ // `tts.playout_progress` completion), so also finalize on `tts.end` — see
387
+ // TurnMetricsTrackerOptions.finalizeOnTtsEnd.
388
+ const turnMetrics = new TurnMetricsTracker(
389
+ session.bus,
390
+ (message) => sendJson(socket, message),
391
+ turnMetricsTurns,
392
+ { finalizeOnTtsEnd: true },
393
+ );
394
+ turnMetrics.wire(disposers);
379
395
  const onSession = <K extends keyof VoiceAgentSessionEvents>(
380
396
  event: K,
381
397
  handler: VoiceAgentSessionEvents[K],
@@ -8,8 +8,11 @@ import {
8
8
  type PipelineBus,
9
9
  type SttResultPacket,
10
10
  type TextToSpeechAudioPacket,
11
+ type TextToSpeechEndPacket,
11
12
  type TextToSpeechPlayoutProgressPacket,
12
13
  type TextToSpeechPlayoutStartedPacket,
14
+ type TurnEndOwner,
15
+ type TurnEndReason,
13
16
  type VadSpeechEndedPacket,
14
17
  } from "@kuralle-syrinx/core";
15
18
 
@@ -22,6 +25,12 @@ export interface TurnTimestampState {
22
25
  firstAudioByteMs: number;
23
26
  firstAudioPlayedMs: number;
24
27
  lastAudioPlayedMs: number;
28
+ /** Who decided the turn ended — captured from the completing eos packet. */
29
+ endpointingOwner?: TurnEndOwner;
30
+ /** Why the turn ended — captured from the completing eos packet. */
31
+ endpointingReason?: TurnEndReason;
32
+ /** Set when the STT force-finalize watchdog fired for this turn. */
33
+ forceFinalized?: boolean;
25
34
  }
26
35
 
27
36
  export interface BrowserMetricsMessage {
@@ -43,8 +52,36 @@ export interface BrowserMetricsMessage {
43
52
  readonly endpointDelayMs?: number;
44
53
  readonly totalMs?: number;
45
54
  };
55
+ /** Which owner decided the turn ended. Omitted when the backend did not say. */
56
+ readonly endpointingOwner?: TurnEndOwner;
57
+ /** Why the turn ended. Omitted when the backend did not say. */
58
+ readonly endpointingReason?: TurnEndReason;
46
59
  }
47
60
 
61
+ /**
62
+ * The field set `buildBrowserMetricsMessage` populates for an audio-originated turn
63
+ * (`vad.speech_ended` + `stt.result` + `llm.delta` + `tts.audio` all measured) —
64
+ * everything EXCEPT `firstAudioPlayedMs`/`lastAudioPlayedMs`, which depend on a
65
+ * playout-completion signal whose *source* legitimately differs per transport
66
+ * (Node's browser websocket path paces server-side; the Workers/DO edge path is
67
+ * client-paced). Both runtimes call this same builder and must emit this same core
68
+ * set; the parity tests in both packages assert against this constant so a runtime
69
+ * that silently drops the `metrics` message, or diverges in shape, fails loudly.
70
+ */
71
+ export const CORE_METRICS_FIELDS = [
72
+ "type",
73
+ "turnId",
74
+ "correlationId",
75
+ "speechEndMs",
76
+ "textReadyMs",
77
+ "firstAudioByteMs",
78
+ "sttMs",
79
+ "llmTTFTMs",
80
+ "ttsTTFBMs",
81
+ "e2eMs",
82
+ "eouBudgetMs",
83
+ ] as const;
84
+
48
85
  function positiveDelta(endMs: number, startMs: number): number | undefined {
49
86
  if (endMs <= 0 || startMs <= 0 || endMs < startMs) return undefined;
50
87
  return endMs - startMs;
@@ -81,6 +118,15 @@ export function buildBrowserMetricsMessage(
81
118
  }
82
119
  : undefined;
83
120
 
121
+ // The endpointing decision is a fact about the turn, not a timing measurement, so
122
+ // it is emitted whenever it is known — never coerced to a zero. A force-finalize
123
+ // watchdog firing overrides the emitter's reason: the STT plugin that produced the
124
+ // completing eos cannot know the watchdog fired, but this mark (stt.force_finalized)
125
+ // arrives independently on the bus, so the truth wins here regardless of emitter.
126
+ const endpointingOwner = timestamps.endpointingOwner;
127
+ const endpointingReason =
128
+ timestamps.forceFinalized === true ? "force_finalized" : timestamps.endpointingReason;
129
+
84
130
  return {
85
131
  type: "metrics",
86
132
  turnId,
@@ -95,6 +141,8 @@ export function buildBrowserMetricsMessage(
95
141
  ...(ttsTTFBMs !== undefined ? { ttsTTFBMs } : {}),
96
142
  ...(e2eFromPlayout !== undefined ? { e2eMs: e2eFromPlayout } : e2eFromByte !== undefined ? { e2eMs: e2eFromByte } : {}),
97
143
  ...(eouBudgetMs !== undefined ? { eouBudgetMs } : {}),
144
+ ...(endpointingOwner !== undefined ? { endpointingOwner } : {}),
145
+ ...(endpointingReason !== undefined ? { endpointingReason } : {}),
98
146
  };
99
147
  }
100
148
 
@@ -111,6 +159,23 @@ function emptyTurnState(): TurnTimestampState {
111
159
  };
112
160
  }
113
161
 
162
+ export interface TurnMetricsTrackerOptions {
163
+ /**
164
+ * Additionally finalize (emit + clear) a turn's metrics on `tts.end` when no
165
+ * `tts.playout_progress` completion has arrived yet. The Node browser websocket
166
+ * path always gets a `tts.playout_progress` completion shortly after `tts.end`
167
+ * from its own server-side pacer (playout-progress.ts) — turning this on there
168
+ * would race a premature, playout-less emission ahead of the real one, so it
169
+ * defaults off and Node leaves it unset. The Workers/DO edge path is
170
+ * client-paced (the browser reports playout over the wire); a client that never
171
+ * reports it — a non-browser client, or a text-only smoke probe — would
172
+ * otherwise leave a turn's metrics pending forever, so edge opts in. If a
173
+ * richer client-reported completion arrives after this floor already fired,
174
+ * it is a no-op: the turn was already finalized and cleared.
175
+ */
176
+ readonly finalizeOnTtsEnd?: boolean;
177
+ }
178
+
114
179
  export class TurnMetricsTracker {
115
180
  private readonly turns: Map<string, TurnTimestampState>;
116
181
 
@@ -118,6 +183,7 @@ export class TurnMetricsTracker {
118
183
  private readonly bus: PipelineBus,
119
184
  private readonly onEmit: (message: BrowserMetricsMessage) => void,
120
185
  persistedTurns?: Map<string, TurnTimestampState>,
186
+ private readonly options: TurnMetricsTrackerOptions = {},
121
187
  ) {
122
188
  this.turns = persistedTurns ?? new Map();
123
189
  }
@@ -136,16 +202,29 @@ export class TurnMetricsTracker {
136
202
  }),
137
203
  this.bus.on("metric.conversation", (pkt) => {
138
204
  const metric = pkt as ConversationMetricPacket;
139
- if (metric.name !== "vad.stop_hangover_ms") return;
140
- const hangoverMs = Number(metric.value);
141
- if (Number.isNaN(hangoverMs)) return;
142
- const state = this.turnState(metric.contextId);
143
- if (state.vadStopHangoverMs === 0) state.vadStopHangoverMs = hangoverMs;
205
+ if (metric.name === "vad.stop_hangover_ms") {
206
+ const hangoverMs = Number(metric.value);
207
+ if (Number.isNaN(hangoverMs)) return;
208
+ const state = this.turnState(metric.contextId);
209
+ if (state.vadStopHangoverMs === 0) state.vadStopHangoverMs = hangoverMs;
210
+ return;
211
+ }
212
+ if (metric.name === "stt.force_finalized") {
213
+ this.turnState(metric.contextId).forceFinalized = true;
214
+ }
144
215
  }),
145
216
  this.bus.on("eos.turn_complete", (pkt) => {
146
217
  const eos = pkt as EndOfSpeechPacket;
147
218
  const state = this.turnState(eos.contextId);
148
219
  if (state.eosMs === 0) state.eosMs = eos.timestampMs;
220
+ // First eos wins (mirrors eosMs): the completing eos is the first, and any
221
+ // duplicate is dropped downstream. Owner/reason are facts, not measurements.
222
+ if (state.endpointingOwner === undefined && eos.endpointingOwner !== undefined) {
223
+ state.endpointingOwner = eos.endpointingOwner;
224
+ }
225
+ if (state.endpointingReason === undefined && eos.endpointingReason !== undefined) {
226
+ state.endpointingReason = eos.endpointingReason;
227
+ }
149
228
  }),
150
229
  this.bus.on("llm.delta", (pkt) => {
151
230
  const delta = pkt as LlmDeltaPacket;
@@ -170,12 +249,35 @@ export class TurnMetricsTracker {
170
249
  const progress = pkt as TextToSpeechPlayoutProgressPacket;
171
250
  const state = this.turns.get(progress.contextId);
172
251
  if (!state) return;
252
+ // The edge transport never emits `tts.playout_started` — the browser reports
253
+ // its own playout clock and that is forwarded here (edge.ts). So take the
254
+ // first progress report as the moment audio started playing. On the Node path
255
+ // `playout_started` has already set this, and the `=== 0` guard leaves it be.
256
+ if (state.firstAudioPlayedMs === 0) state.firstAudioPlayedMs = progress.timestampMs;
173
257
  if (progress.complete) {
174
258
  state.lastAudioPlayedMs = progress.timestampMs;
175
259
  this.onEmit(buildBrowserMetricsMessage(progress.contextId, state));
176
260
  this.turns.delete(progress.contextId);
177
261
  }
178
262
  }),
263
+ this.bus.on("tts.end", (pkt) => {
264
+ if (!this.options.finalizeOnTtsEnd) return;
265
+ const end = pkt as TextToSpeechEndPacket;
266
+ const state = this.turns.get(end.contextId);
267
+ if (!state) return;
268
+ // `tts.end` means synthesis finished, which is EARLIER than playback finishing.
269
+ // So if this client is pacing playout, firing here would emit a poorer message
270
+ // and delete the turn before the real completion arrived — costing
271
+ // firstAudioPlayedMs, lastAudioPlayedMs, and downgrading e2eMs from
272
+ // "to first audio played" to "to first byte" on this runtime only. That is the
273
+ // exact runtime drift this tracker exists to prevent.
274
+ //
275
+ // A non-zero firstAudioPlayedMs means playout has been reported, so wait.
276
+ // The floor is only for clients that never report at all.
277
+ if (state.firstAudioPlayedMs !== 0) return;
278
+ this.onEmit(buildBrowserMetricsMessage(end.contextId, state));
279
+ this.turns.delete(end.contextId);
280
+ }),
179
281
  this.bus.on("interrupt.tts", (pkt) => {
180
282
  this.turns.delete((pkt as InterruptTtsPacket).contextId);
181
283
  }),