@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 +3 -3
- package/src/edge.ts +16 -0
- package/src/turn-metrics.ts +107 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
39
|
-
"@kuralle-syrinx/ws": "4.
|
|
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],
|
package/src/turn-metrics.ts
CHANGED
|
@@ -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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}),
|