@kuralle-syrinx/server-websocket 2.1.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/LICENSE +22 -0
- package/package.json +31 -0
- package/src/admission-control.test.ts +247 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-opus.ts +59 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/edge-twilio.test.ts +215 -0
- package/src/edge-twilio.ts +285 -0
- package/src/edge.test.ts +272 -0
- package/src/edge.ts +652 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.ts +102 -0
- package/src/index.test.ts +2071 -0
- package/src/index.ts +891 -0
- package/src/json-message.ts +39 -0
- package/src/outbound-playout-pipeline.test.ts +208 -0
- package/src/outbound-playout-pipeline.ts +167 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/paced-playout.ts +161 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/playout-progress.ts +67 -0
- package/src/session-store.test.ts +274 -0
- package/src/session-store.ts +123 -0
- package/src/smartpbx.test.ts +967 -0
- package/src/smartpbx.ts +531 -0
- package/src/telnyx.test.ts +1413 -0
- package/src/telnyx.ts +708 -0
- package/src/test-helpers.ts +264 -0
- package/src/transport-helpers.ts +78 -0
- package/src/transport-host.test.ts +51 -0
- package/src/transport-host.ts +213 -0
- package/src/turn-metrics.test.ts +327 -0
- package/src/turn-metrics.ts +193 -0
- package/src/twilio.test.ts +1275 -0
- package/src/twilio.ts +599 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-close.ts +68 -0
- package/src/websocket-lifecycle.test.ts +49 -0
- package/src/websocket-lifecycle.ts +105 -0
- package/src/websocket-upgrade.ts +102 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
5
|
+
import {
|
|
6
|
+
buildBrowserMetricsMessage,
|
|
7
|
+
TurnMetricsTracker,
|
|
8
|
+
} from "./turn-metrics.js";
|
|
9
|
+
import { waitForCondition } from "./test-helpers.js";
|
|
10
|
+
|
|
11
|
+
describe("turn metrics", () => {
|
|
12
|
+
it("computes stage latencies from synthetic timestamps", () => {
|
|
13
|
+
const message = buildBrowserMetricsMessage("turn-a", {
|
|
14
|
+
speechEndMs: 1000,
|
|
15
|
+
sttFinalMs: 1200,
|
|
16
|
+
eosMs: 0,
|
|
17
|
+
vadStopHangoverMs: 0,
|
|
18
|
+
textReadyMs: 1500,
|
|
19
|
+
firstAudioByteMs: 1700,
|
|
20
|
+
firstAudioPlayedMs: 1900,
|
|
21
|
+
lastAudioPlayedMs: 2500,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(message).toEqual({
|
|
25
|
+
type: "metrics",
|
|
26
|
+
turnId: "turn-a",
|
|
27
|
+
correlationId: "turn-a",
|
|
28
|
+
speechEndMs: 1000,
|
|
29
|
+
textReadyMs: 1500,
|
|
30
|
+
firstAudioByteMs: 1700,
|
|
31
|
+
firstAudioPlayedMs: 1900,
|
|
32
|
+
lastAudioPlayedMs: 2500,
|
|
33
|
+
sttMs: 200,
|
|
34
|
+
llmTTFTMs: 300,
|
|
35
|
+
ttsTTFBMs: 200,
|
|
36
|
+
e2eMs: 900,
|
|
37
|
+
eouBudgetMs: {
|
|
38
|
+
sttFinalDelayMs: 200,
|
|
39
|
+
totalMs: 200,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("buildBrowserMetricsMessage eou budget sums hangover, stt-final, and endpoint delays", () => {
|
|
45
|
+
const message = buildBrowserMetricsMessage("turn-eou-unit", {
|
|
46
|
+
speechEndMs: 1000,
|
|
47
|
+
sttFinalMs: 1250,
|
|
48
|
+
eosMs: 1300,
|
|
49
|
+
vadStopHangoverMs: 80,
|
|
50
|
+
textReadyMs: 0,
|
|
51
|
+
firstAudioByteMs: 0,
|
|
52
|
+
firstAudioPlayedMs: 0,
|
|
53
|
+
lastAudioPlayedMs: 0,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(message.eouBudgetMs).toEqual({
|
|
57
|
+
vadStopHangoverMs: 80,
|
|
58
|
+
sttFinalDelayMs: 250,
|
|
59
|
+
endpointDelayMs: 50,
|
|
60
|
+
totalMs: 380,
|
|
61
|
+
});
|
|
62
|
+
expect(message.sttMs).toBe(250);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps correlation id stable for the turn context", async () => {
|
|
66
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
67
|
+
const emitted: unknown[] = [];
|
|
68
|
+
const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
|
|
69
|
+
const disposers: Array<() => void> = [];
|
|
70
|
+
tracker.wire(disposers);
|
|
71
|
+
void session.start();
|
|
72
|
+
|
|
73
|
+
session.bus.push(Route.Main, {
|
|
74
|
+
kind: "vad.speech_ended",
|
|
75
|
+
contextId: "turn-correlation",
|
|
76
|
+
timestampMs: 500,
|
|
77
|
+
});
|
|
78
|
+
session.bus.push(Route.Main, {
|
|
79
|
+
kind: "stt.result",
|
|
80
|
+
contextId: "turn-correlation",
|
|
81
|
+
timestampMs: 700,
|
|
82
|
+
text: "hello",
|
|
83
|
+
confidence: 0.99,
|
|
84
|
+
});
|
|
85
|
+
session.bus.push(Route.Main, {
|
|
86
|
+
kind: "llm.delta",
|
|
87
|
+
contextId: "turn-correlation",
|
|
88
|
+
timestampMs: 900,
|
|
89
|
+
text: "hi",
|
|
90
|
+
});
|
|
91
|
+
session.bus.push(Route.Main, {
|
|
92
|
+
kind: "tts.audio",
|
|
93
|
+
contextId: "turn-correlation",
|
|
94
|
+
timestampMs: 1100,
|
|
95
|
+
audio: new Uint8Array(640),
|
|
96
|
+
sampleRateHz: 16000,
|
|
97
|
+
});
|
|
98
|
+
session.bus.push(Route.Main, {
|
|
99
|
+
kind: "tts.playout_started",
|
|
100
|
+
contextId: "turn-correlation",
|
|
101
|
+
timestampMs: 1100,
|
|
102
|
+
});
|
|
103
|
+
session.bus.push(Route.Main, {
|
|
104
|
+
kind: "tts.playout_progress",
|
|
105
|
+
contextId: "turn-correlation",
|
|
106
|
+
timestampMs: 1300,
|
|
107
|
+
playedOutMs: 200,
|
|
108
|
+
complete: false,
|
|
109
|
+
});
|
|
110
|
+
session.bus.push(Route.Main, {
|
|
111
|
+
kind: "tts.playout_progress",
|
|
112
|
+
contextId: "turn-correlation",
|
|
113
|
+
timestampMs: 1800,
|
|
114
|
+
playedOutMs: 120,
|
|
115
|
+
complete: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await waitForCondition(() => emitted.length === 1);
|
|
119
|
+
expect(emitted[0]).toMatchObject({
|
|
120
|
+
type: "metrics",
|
|
121
|
+
turnId: "turn-correlation",
|
|
122
|
+
correlationId: "turn-correlation",
|
|
123
|
+
sttMs: 200,
|
|
124
|
+
llmTTFTMs: 200,
|
|
125
|
+
ttsTTFBMs: 200,
|
|
126
|
+
e2eMs: 600,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (const dispose of disposers) dispose();
|
|
130
|
+
await session.close();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("records firstAudioPlayedMs from playout_started, not throttled progress", async () => {
|
|
134
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
135
|
+
const emitted: unknown[] = [];
|
|
136
|
+
const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
|
|
137
|
+
const disposers: Array<() => void> = [];
|
|
138
|
+
tracker.wire(disposers);
|
|
139
|
+
void session.start();
|
|
140
|
+
|
|
141
|
+
session.bus.push(Route.Main, {
|
|
142
|
+
kind: "vad.speech_ended",
|
|
143
|
+
contextId: "turn-throttle",
|
|
144
|
+
timestampMs: 1000,
|
|
145
|
+
});
|
|
146
|
+
session.bus.push(Route.Main, {
|
|
147
|
+
kind: "tts.playout_started",
|
|
148
|
+
contextId: "turn-throttle",
|
|
149
|
+
timestampMs: 1100,
|
|
150
|
+
});
|
|
151
|
+
session.bus.push(Route.Main, {
|
|
152
|
+
kind: "tts.playout_progress",
|
|
153
|
+
contextId: "turn-throttle",
|
|
154
|
+
timestampMs: 1300,
|
|
155
|
+
playedOutMs: 200,
|
|
156
|
+
complete: true,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await waitForCondition(() => emitted.length === 1);
|
|
160
|
+
expect(emitted[0]).toMatchObject({
|
|
161
|
+
firstAudioPlayedMs: 1100,
|
|
162
|
+
e2eMs: 100,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
for (const dispose of disposers) dispose();
|
|
166
|
+
await session.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("emits metrics once when playout completes for a wired browser session", async () => {
|
|
170
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
171
|
+
const emitted: unknown[] = [];
|
|
172
|
+
const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
|
|
173
|
+
const disposers: Array<() => void> = [];
|
|
174
|
+
tracker.wire(disposers);
|
|
175
|
+
void session.start();
|
|
176
|
+
|
|
177
|
+
session.bus.push(Route.Main, {
|
|
178
|
+
kind: "vad.speech_ended",
|
|
179
|
+
contextId: "turn-live",
|
|
180
|
+
timestampMs: 10_000,
|
|
181
|
+
});
|
|
182
|
+
session.bus.push(Route.Main, {
|
|
183
|
+
kind: "stt.result",
|
|
184
|
+
contextId: "turn-live",
|
|
185
|
+
timestampMs: 10_200,
|
|
186
|
+
text: "hello",
|
|
187
|
+
confidence: 0.99,
|
|
188
|
+
});
|
|
189
|
+
session.bus.push(Route.Main, {
|
|
190
|
+
kind: "llm.delta",
|
|
191
|
+
contextId: "turn-live",
|
|
192
|
+
timestampMs: 10_450,
|
|
193
|
+
text: "hi",
|
|
194
|
+
});
|
|
195
|
+
session.bus.push(Route.Main, {
|
|
196
|
+
kind: "tts.audio",
|
|
197
|
+
contextId: "turn-live",
|
|
198
|
+
timestampMs: 10_600,
|
|
199
|
+
audio: new Uint8Array(640),
|
|
200
|
+
sampleRateHz: 16000,
|
|
201
|
+
});
|
|
202
|
+
session.bus.push(Route.Main, {
|
|
203
|
+
kind: "tts.playout_started",
|
|
204
|
+
contextId: "turn-live",
|
|
205
|
+
timestampMs: 10_600,
|
|
206
|
+
});
|
|
207
|
+
session.bus.push(Route.Main, {
|
|
208
|
+
kind: "tts.playout_progress",
|
|
209
|
+
contextId: "turn-live",
|
|
210
|
+
timestampMs: 10_800,
|
|
211
|
+
playedOutMs: 200,
|
|
212
|
+
complete: false,
|
|
213
|
+
});
|
|
214
|
+
session.bus.push(Route.Main, {
|
|
215
|
+
kind: "tts.playout_progress",
|
|
216
|
+
contextId: "turn-live",
|
|
217
|
+
timestampMs: 11_000,
|
|
218
|
+
playedOutMs: 120,
|
|
219
|
+
complete: true,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await waitForCondition(() => emitted.length === 1);
|
|
223
|
+
expect(emitted[0]).toMatchObject({
|
|
224
|
+
type: "metrics",
|
|
225
|
+
turnId: "turn-live",
|
|
226
|
+
correlationId: "turn-live",
|
|
227
|
+
sttMs: 200,
|
|
228
|
+
llmTTFTMs: 250,
|
|
229
|
+
ttsTTFBMs: 150,
|
|
230
|
+
e2eMs: 600,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
for (const dispose of disposers) dispose();
|
|
234
|
+
await session.close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("eou_budget_breakdown: vad hangover, stt-final delay, endpoint delay, and total", async () => {
|
|
238
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
239
|
+
const emitted: unknown[] = [];
|
|
240
|
+
const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
|
|
241
|
+
const disposers: Array<() => void> = [];
|
|
242
|
+
tracker.wire(disposers);
|
|
243
|
+
void session.start();
|
|
244
|
+
|
|
245
|
+
session.bus.push(Route.Main, {
|
|
246
|
+
kind: "vad.speech_ended",
|
|
247
|
+
contextId: "turn-eou",
|
|
248
|
+
timestampMs: 1000,
|
|
249
|
+
});
|
|
250
|
+
session.bus.push(Route.Main, {
|
|
251
|
+
kind: "metric.conversation",
|
|
252
|
+
contextId: "turn-eou",
|
|
253
|
+
timestampMs: 1005,
|
|
254
|
+
name: "vad.stop_hangover_ms",
|
|
255
|
+
value: "80",
|
|
256
|
+
});
|
|
257
|
+
session.bus.push(Route.Main, {
|
|
258
|
+
kind: "stt.result",
|
|
259
|
+
contextId: "turn-eou",
|
|
260
|
+
timestampMs: 1250,
|
|
261
|
+
text: "hello",
|
|
262
|
+
confidence: 0.99,
|
|
263
|
+
});
|
|
264
|
+
session.bus.push(Route.Main, {
|
|
265
|
+
kind: "eos.turn_complete",
|
|
266
|
+
contextId: "turn-eou",
|
|
267
|
+
timestampMs: 1300,
|
|
268
|
+
text: "hello",
|
|
269
|
+
transcripts: [],
|
|
270
|
+
});
|
|
271
|
+
session.bus.push(Route.Main, {
|
|
272
|
+
kind: "tts.playout_progress",
|
|
273
|
+
contextId: "turn-eou",
|
|
274
|
+
timestampMs: 1500,
|
|
275
|
+
playedOutMs: 100,
|
|
276
|
+
complete: true,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await waitForCondition(() => emitted.length === 1);
|
|
280
|
+
expect(emitted[0]).toMatchObject({
|
|
281
|
+
type: "metrics",
|
|
282
|
+
turnId: "turn-eou",
|
|
283
|
+
correlationId: "turn-eou",
|
|
284
|
+
sttMs: 250,
|
|
285
|
+
eouBudgetMs: {
|
|
286
|
+
vadStopHangoverMs: 80,
|
|
287
|
+
sttFinalDelayMs: 250,
|
|
288
|
+
endpointDelayMs: 50,
|
|
289
|
+
totalMs: 380,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
for (const dispose of disposers) dispose();
|
|
294
|
+
await session.close();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("drops partial turn state on interrupt without emitting metrics", async () => {
|
|
298
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
299
|
+
const emit = vi.fn();
|
|
300
|
+
const tracker = new TurnMetricsTracker(session.bus, emit);
|
|
301
|
+
tracker.wire([]);
|
|
302
|
+
void session.start();
|
|
303
|
+
|
|
304
|
+
session.bus.push(Route.Main, {
|
|
305
|
+
kind: "vad.speech_ended",
|
|
306
|
+
contextId: "turn-interrupted",
|
|
307
|
+
timestampMs: 1000,
|
|
308
|
+
});
|
|
309
|
+
session.bus.push(Route.Critical, {
|
|
310
|
+
kind: "interrupt.tts",
|
|
311
|
+
contextId: "turn-interrupted",
|
|
312
|
+
timestampMs: 1100,
|
|
313
|
+
});
|
|
314
|
+
session.bus.push(Route.Main, {
|
|
315
|
+
kind: "tts.playout_progress",
|
|
316
|
+
contextId: "turn-interrupted",
|
|
317
|
+
timestampMs: 1200,
|
|
318
|
+
playedOutMs: 20,
|
|
319
|
+
complete: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await waitForCondition(() => emit.mock.calls.length === 0, 200);
|
|
323
|
+
expect(emit).not.toHaveBeenCalled();
|
|
324
|
+
|
|
325
|
+
await session.close();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ConversationMetricPacket,
|
|
5
|
+
type EndOfSpeechPacket,
|
|
6
|
+
type InterruptTtsPacket,
|
|
7
|
+
type LlmDeltaPacket,
|
|
8
|
+
type PipelineBus,
|
|
9
|
+
type SttResultPacket,
|
|
10
|
+
type TextToSpeechAudioPacket,
|
|
11
|
+
type TextToSpeechPlayoutProgressPacket,
|
|
12
|
+
type TextToSpeechPlayoutStartedPacket,
|
|
13
|
+
type VadSpeechEndedPacket,
|
|
14
|
+
} from "@kuralle-syrinx/core";
|
|
15
|
+
|
|
16
|
+
export interface TurnTimestampState {
|
|
17
|
+
speechEndMs: number;
|
|
18
|
+
sttFinalMs: number;
|
|
19
|
+
eosMs: number;
|
|
20
|
+
vadStopHangoverMs: number;
|
|
21
|
+
textReadyMs: number;
|
|
22
|
+
firstAudioByteMs: number;
|
|
23
|
+
firstAudioPlayedMs: number;
|
|
24
|
+
lastAudioPlayedMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BrowserMetricsMessage {
|
|
28
|
+
readonly type: "metrics";
|
|
29
|
+
readonly turnId: string;
|
|
30
|
+
readonly correlationId: string;
|
|
31
|
+
readonly speechEndMs?: number;
|
|
32
|
+
readonly textReadyMs?: number;
|
|
33
|
+
readonly firstAudioByteMs?: number;
|
|
34
|
+
readonly firstAudioPlayedMs?: number;
|
|
35
|
+
readonly lastAudioPlayedMs?: number;
|
|
36
|
+
readonly sttMs?: number;
|
|
37
|
+
readonly llmTTFTMs?: number;
|
|
38
|
+
readonly ttsTTFBMs?: number;
|
|
39
|
+
readonly e2eMs?: number;
|
|
40
|
+
readonly eouBudgetMs?: {
|
|
41
|
+
readonly vadStopHangoverMs?: number;
|
|
42
|
+
readonly sttFinalDelayMs?: number;
|
|
43
|
+
readonly endpointDelayMs?: number;
|
|
44
|
+
readonly totalMs?: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function positiveDelta(endMs: number, startMs: number): number | undefined {
|
|
49
|
+
if (endMs <= 0 || startMs <= 0 || endMs < startMs) return undefined;
|
|
50
|
+
return endMs - startMs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildBrowserMetricsMessage(
|
|
54
|
+
turnId: string,
|
|
55
|
+
timestamps: TurnTimestampState,
|
|
56
|
+
): BrowserMetricsMessage {
|
|
57
|
+
const sttMs = positiveDelta(timestamps.sttFinalMs, timestamps.speechEndMs);
|
|
58
|
+
const llmTTFTMs = positiveDelta(timestamps.textReadyMs, timestamps.sttFinalMs);
|
|
59
|
+
const ttsTTFBMs = positiveDelta(timestamps.firstAudioByteMs, timestamps.textReadyMs);
|
|
60
|
+
const e2eFromPlayout = positiveDelta(timestamps.firstAudioPlayedMs, timestamps.speechEndMs);
|
|
61
|
+
const e2eFromByte = positiveDelta(timestamps.firstAudioByteMs, timestamps.speechEndMs);
|
|
62
|
+
|
|
63
|
+
const sttFinalDelayMs = positiveDelta(timestamps.sttFinalMs, timestamps.speechEndMs);
|
|
64
|
+
const endpointDelayMs = positiveDelta(timestamps.eosMs, timestamps.sttFinalMs);
|
|
65
|
+
const vadStopHangoverMs =
|
|
66
|
+
timestamps.vadStopHangoverMs > 0 ? timestamps.vadStopHangoverMs : undefined;
|
|
67
|
+
const eouTotalMs =
|
|
68
|
+
(vadStopHangoverMs ?? 0) +
|
|
69
|
+
(positiveDelta(timestamps.eosMs, timestamps.speechEndMs) ?? sttFinalDelayMs ?? 0) ||
|
|
70
|
+
undefined;
|
|
71
|
+
const eouBudgetMs =
|
|
72
|
+
vadStopHangoverMs !== undefined ||
|
|
73
|
+
sttFinalDelayMs !== undefined ||
|
|
74
|
+
endpointDelayMs !== undefined ||
|
|
75
|
+
eouTotalMs !== undefined
|
|
76
|
+
? {
|
|
77
|
+
...(vadStopHangoverMs !== undefined ? { vadStopHangoverMs } : {}),
|
|
78
|
+
...(sttFinalDelayMs !== undefined ? { sttFinalDelayMs } : {}),
|
|
79
|
+
...(endpointDelayMs !== undefined ? { endpointDelayMs } : {}),
|
|
80
|
+
...(eouTotalMs !== undefined ? { totalMs: eouTotalMs } : {}),
|
|
81
|
+
}
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
type: "metrics",
|
|
86
|
+
turnId,
|
|
87
|
+
correlationId: turnId,
|
|
88
|
+
...(timestamps.speechEndMs > 0 ? { speechEndMs: timestamps.speechEndMs } : {}),
|
|
89
|
+
...(timestamps.textReadyMs > 0 ? { textReadyMs: timestamps.textReadyMs } : {}),
|
|
90
|
+
...(timestamps.firstAudioByteMs > 0 ? { firstAudioByteMs: timestamps.firstAudioByteMs } : {}),
|
|
91
|
+
...(timestamps.firstAudioPlayedMs > 0 ? { firstAudioPlayedMs: timestamps.firstAudioPlayedMs } : {}),
|
|
92
|
+
...(timestamps.lastAudioPlayedMs > 0 ? { lastAudioPlayedMs: timestamps.lastAudioPlayedMs } : {}),
|
|
93
|
+
...(sttMs !== undefined ? { sttMs } : {}),
|
|
94
|
+
...(llmTTFTMs !== undefined ? { llmTTFTMs } : {}),
|
|
95
|
+
...(ttsTTFBMs !== undefined ? { ttsTTFBMs } : {}),
|
|
96
|
+
...(e2eFromPlayout !== undefined ? { e2eMs: e2eFromPlayout } : e2eFromByte !== undefined ? { e2eMs: e2eFromByte } : {}),
|
|
97
|
+
...(eouBudgetMs !== undefined ? { eouBudgetMs } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function emptyTurnState(): TurnTimestampState {
|
|
102
|
+
return {
|
|
103
|
+
speechEndMs: 0,
|
|
104
|
+
sttFinalMs: 0,
|
|
105
|
+
eosMs: 0,
|
|
106
|
+
vadStopHangoverMs: 0,
|
|
107
|
+
textReadyMs: 0,
|
|
108
|
+
firstAudioByteMs: 0,
|
|
109
|
+
firstAudioPlayedMs: 0,
|
|
110
|
+
lastAudioPlayedMs: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class TurnMetricsTracker {
|
|
115
|
+
private readonly turns: Map<string, TurnTimestampState>;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly bus: PipelineBus,
|
|
119
|
+
private readonly onEmit: (message: BrowserMetricsMessage) => void,
|
|
120
|
+
persistedTurns?: Map<string, TurnTimestampState>,
|
|
121
|
+
) {
|
|
122
|
+
this.turns = persistedTurns ?? new Map();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
wire(disposers: Array<() => void>): void {
|
|
126
|
+
disposers.push(
|
|
127
|
+
this.bus.on("vad.speech_ended", (pkt) => {
|
|
128
|
+
const ended = pkt as VadSpeechEndedPacket;
|
|
129
|
+
const state = this.turnState(ended.contextId);
|
|
130
|
+
if (state.speechEndMs === 0) state.speechEndMs = ended.timestampMs;
|
|
131
|
+
}),
|
|
132
|
+
this.bus.on("stt.result", (pkt) => {
|
|
133
|
+
const result = pkt as SttResultPacket;
|
|
134
|
+
const state = this.turnState(result.contextId);
|
|
135
|
+
if (state.sttFinalMs === 0) state.sttFinalMs = result.timestampMs;
|
|
136
|
+
}),
|
|
137
|
+
this.bus.on("metric.conversation", (pkt) => {
|
|
138
|
+
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;
|
|
144
|
+
}),
|
|
145
|
+
this.bus.on("eos.turn_complete", (pkt) => {
|
|
146
|
+
const eos = pkt as EndOfSpeechPacket;
|
|
147
|
+
const state = this.turnState(eos.contextId);
|
|
148
|
+
if (state.eosMs === 0) state.eosMs = eos.timestampMs;
|
|
149
|
+
}),
|
|
150
|
+
this.bus.on("llm.delta", (pkt) => {
|
|
151
|
+
const delta = pkt as LlmDeltaPacket;
|
|
152
|
+
if (delta.text.length === 0) return;
|
|
153
|
+
const state = this.turnState(delta.contextId);
|
|
154
|
+
if (state.textReadyMs === 0) state.textReadyMs = delta.timestampMs;
|
|
155
|
+
}),
|
|
156
|
+
this.bus.on("tts.audio", (pkt) => {
|
|
157
|
+
const audio = pkt as TextToSpeechAudioPacket;
|
|
158
|
+
const state = this.turnState(audio.contextId);
|
|
159
|
+
if (state.firstAudioByteMs === 0) state.firstAudioByteMs = audio.timestampMs;
|
|
160
|
+
}),
|
|
161
|
+
this.bus.on("tts.playout_started", (pkt) => {
|
|
162
|
+
const started = pkt as TextToSpeechPlayoutStartedPacket;
|
|
163
|
+
const state = this.turns.get(started.contextId);
|
|
164
|
+
if (!state) return;
|
|
165
|
+
if (state.firstAudioPlayedMs === 0) {
|
|
166
|
+
state.firstAudioPlayedMs = started.timestampMs;
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
this.bus.on("tts.playout_progress", (pkt) => {
|
|
170
|
+
const progress = pkt as TextToSpeechPlayoutProgressPacket;
|
|
171
|
+
const state = this.turns.get(progress.contextId);
|
|
172
|
+
if (!state) return;
|
|
173
|
+
if (progress.complete) {
|
|
174
|
+
state.lastAudioPlayedMs = progress.timestampMs;
|
|
175
|
+
this.onEmit(buildBrowserMetricsMessage(progress.contextId, state));
|
|
176
|
+
this.turns.delete(progress.contextId);
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
this.bus.on("interrupt.tts", (pkt) => {
|
|
180
|
+
this.turns.delete((pkt as InterruptTtsPacket).contextId);
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private turnState(contextId: string): TurnTimestampState {
|
|
186
|
+
let state = this.turns.get(contextId);
|
|
187
|
+
if (!state) {
|
|
188
|
+
state = emptyTurnState();
|
|
189
|
+
this.turns.set(contextId, state);
|
|
190
|
+
}
|
|
191
|
+
return state;
|
|
192
|
+
}
|
|
193
|
+
}
|