@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,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export function parseJsonRecord(text: string, name: string): Record<string, unknown> {
|
|
4
|
+
const parsed = JSON.parse(text) as unknown;
|
|
5
|
+
if (!isRecord(parsed)) throw new Error(`${name} must be an object`);
|
|
6
|
+
return parsed;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function optionalRecord(value: unknown, name: string): Record<string, unknown> | undefined {
|
|
14
|
+
if (value === undefined) return undefined;
|
|
15
|
+
if (!isRecord(value)) throw new Error(`${name} must be a JSON object`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requiredString(value: unknown, name: string): string {
|
|
20
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
21
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function optionalString(value: unknown, name: string): string | undefined {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
29
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function optionalStringOrNumber(value: unknown, name: string): string | number | undefined {
|
|
35
|
+
if (value === undefined) return undefined;
|
|
36
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
37
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
38
|
+
throw new Error(`${name} must be a non-empty string or finite number`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
7
|
+
import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
8
|
+
import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
|
|
9
|
+
|
|
10
|
+
function createMockSocket(): WebSocket {
|
|
11
|
+
const emitter = new EventEmitter();
|
|
12
|
+
let readyState: number = WebSocket.OPEN;
|
|
13
|
+
return {
|
|
14
|
+
get readyState() {
|
|
15
|
+
return readyState;
|
|
16
|
+
},
|
|
17
|
+
close: () => {
|
|
18
|
+
readyState = WebSocket.CLOSED;
|
|
19
|
+
emitter.emit("close");
|
|
20
|
+
},
|
|
21
|
+
terminate: () => {
|
|
22
|
+
readyState = WebSocket.CLOSED;
|
|
23
|
+
emitter.emit("close");
|
|
24
|
+
},
|
|
25
|
+
once: (event: string, handler: () => void) => {
|
|
26
|
+
emitter.once(event, handler);
|
|
27
|
+
},
|
|
28
|
+
off: (event: string, handler: () => void) => {
|
|
29
|
+
emitter.off(event, handler);
|
|
30
|
+
},
|
|
31
|
+
} as WebSocket;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function wireTestPipeline(socket: WebSocket): {
|
|
35
|
+
readonly session: VoiceAgentSession;
|
|
36
|
+
readonly handle: ReturnType<typeof wireTelephonyOutboundPipeline>;
|
|
37
|
+
readonly disposers: Array<() => void>;
|
|
38
|
+
} {
|
|
39
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
40
|
+
void session.start();
|
|
41
|
+
const disposers: Array<() => void> = [];
|
|
42
|
+
const handle = wireTelephonyOutboundPipeline({
|
|
43
|
+
session,
|
|
44
|
+
socket,
|
|
45
|
+
disposers,
|
|
46
|
+
outboundFrameDurationMs: 20,
|
|
47
|
+
maxQueuedOutputAudioMs: 30_000,
|
|
48
|
+
callbacks: {
|
|
49
|
+
carrierLabel: "test",
|
|
50
|
+
getContextId: () => "turn-drain",
|
|
51
|
+
isActive: () => true,
|
|
52
|
+
encodeFrames: (audio) => [{
|
|
53
|
+
contextId: "turn-drain",
|
|
54
|
+
send: () => true,
|
|
55
|
+
}],
|
|
56
|
+
onInterrupt: () => undefined,
|
|
57
|
+
onDrain: () => undefined,
|
|
58
|
+
onStop: () => undefined,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return { session, handle, disposers };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeFrames(count: number): Array<{ contextId: string; send: () => boolean }> {
|
|
65
|
+
return Array.from({ length: count }, () => ({
|
|
66
|
+
contextId: "turn-drain",
|
|
67
|
+
send: () => true,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("wireTelephonyOutboundPipeline.interrupt.tts", () => {
|
|
72
|
+
it("clears playout and emits interrupt_onset_to_media_silent_ms", async () => {
|
|
73
|
+
const socket = createMockSocket();
|
|
74
|
+
const { session, disposers } = wireTestPipeline(socket);
|
|
75
|
+
const mediaSilentMetrics: Array<{ name: string; value: string }> = [];
|
|
76
|
+
|
|
77
|
+
session.bus.on("metric.conversation", (pkt) => {
|
|
78
|
+
const metric = pkt as unknown as { name: string; value: string };
|
|
79
|
+
if (metric.name === "test.interrupt_onset_to_media_silent_ms") mediaSilentMetrics.push(metric);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const onset = Date.now() - 25;
|
|
83
|
+
session.bus.push(Route.Critical, {
|
|
84
|
+
kind: "interrupt.tts",
|
|
85
|
+
contextId: "turn-drain",
|
|
86
|
+
timestampMs: onset,
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
89
|
+
|
|
90
|
+
expect(mediaSilentMetrics).toEqual([
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
name: "test.interrupt_onset_to_media_silent_ms",
|
|
93
|
+
value: expect.stringMatching(/^\d+$/),
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
expect(Number(mediaSilentMetrics[0]!.value)).toBeGreaterThanOrEqual(0);
|
|
97
|
+
|
|
98
|
+
for (const dispose of disposers) dispose();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("wireTelephonyOutboundPipeline overflow", () => {
|
|
103
|
+
it("drops the overflow tail at the cap without stopping playout or closing the socket", async () => {
|
|
104
|
+
const socket = createMockSocket();
|
|
105
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
106
|
+
await session.start();
|
|
107
|
+
const disposers: Array<() => void> = [];
|
|
108
|
+
const stops: string[] = [];
|
|
109
|
+
const discardedMetrics: Array<{ name: string; value: string }> = [];
|
|
110
|
+
let encodeCall = 0;
|
|
111
|
+
|
|
112
|
+
wireTelephonyOutboundPipeline({
|
|
113
|
+
session,
|
|
114
|
+
socket,
|
|
115
|
+
disposers,
|
|
116
|
+
outboundFrameDurationMs: 20,
|
|
117
|
+
maxQueuedOutputAudioMs: 200,
|
|
118
|
+
callbacks: {
|
|
119
|
+
carrierLabel: "test",
|
|
120
|
+
getContextId: () => "turn-drain",
|
|
121
|
+
isActive: () => true,
|
|
122
|
+
encodeFrames: () => {
|
|
123
|
+
encodeCall += 1;
|
|
124
|
+
return makeFrames(encodeCall === 1 ? 10 : 2);
|
|
125
|
+
},
|
|
126
|
+
onInterrupt: () => undefined,
|
|
127
|
+
onDrain: () => undefined,
|
|
128
|
+
onStop: (reason) => {
|
|
129
|
+
stops.push(reason);
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
session.bus.on("metric.conversation", (pkt) => {
|
|
134
|
+
const metric = pkt as unknown as { name: string; value: string };
|
|
135
|
+
if (metric.name === "test.overflow_playout_cleared_ms") {
|
|
136
|
+
discardedMetrics.push(metric);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
session.bus.push(Route.Main, {
|
|
141
|
+
kind: "tts.audio",
|
|
142
|
+
contextId: "turn-drain",
|
|
143
|
+
timestampMs: Date.now(),
|
|
144
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
145
|
+
sampleRateHz: 8000,
|
|
146
|
+
});
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
148
|
+
session.bus.push(Route.Main, {
|
|
149
|
+
kind: "tts.audio",
|
|
150
|
+
contextId: "turn-drain",
|
|
151
|
+
timestampMs: Date.now(),
|
|
152
|
+
audio: new Uint8Array([5, 6, 7, 8]),
|
|
153
|
+
sampleRateHz: 8000,
|
|
154
|
+
});
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
156
|
+
|
|
157
|
+
expect(stops).toEqual([]); // overflow is non-fatal — playout continues
|
|
158
|
+
expect(discardedMetrics).toEqual([
|
|
159
|
+
expect.objectContaining({
|
|
160
|
+
name: "test.overflow_playout_cleared_ms",
|
|
161
|
+
value: expect.stringMatching(/^\d+$/),
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
expect(Number(discardedMetrics[0]!.value)).toBeGreaterThan(0);
|
|
165
|
+
expect(socket.readyState).toBe(WebSocket.OPEN);
|
|
166
|
+
|
|
167
|
+
for (const dispose of disposers) dispose();
|
|
168
|
+
await session.close();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("wireTelephonyOutboundPipeline.drainAndClose", () => {
|
|
173
|
+
it("resolves immediately when the playout queue is idle", async () => {
|
|
174
|
+
const socket = createMockSocket();
|
|
175
|
+
const { handle, disposers } = wireTestPipeline(socket);
|
|
176
|
+
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
await handle.drainAndClose(socket, Date.now() + 30_000);
|
|
179
|
+
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
180
|
+
|
|
181
|
+
for (const dispose of disposers) dispose();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("settles promptly when the socket closes before queued playout control runs", async () => {
|
|
185
|
+
const socket = createMockSocket();
|
|
186
|
+
const { session, handle, disposers } = wireTestPipeline(socket);
|
|
187
|
+
|
|
188
|
+
const longAudio = pcm16SamplesToBytes(new Int16Array(8000 * 2));
|
|
189
|
+
session.bus.push(Route.Main, {
|
|
190
|
+
kind: "tts.audio",
|
|
191
|
+
contextId: "turn-drain",
|
|
192
|
+
timestampMs: Date.now(),
|
|
193
|
+
audio: longAudio,
|
|
194
|
+
sampleRateHz: 8000,
|
|
195
|
+
});
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
197
|
+
|
|
198
|
+
const startedAt = Date.now();
|
|
199
|
+
const drainPromise = handle.drainAndClose(socket, Date.now() + 30_000);
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
201
|
+
socket.close();
|
|
202
|
+
await drainPromise;
|
|
203
|
+
|
|
204
|
+
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
205
|
+
|
|
206
|
+
for (const dispose of disposers) dispose();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { Route, type InterruptTtsPacket, type TextToSpeechAudioPacket, type TextToSpeechEndPacket, type VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
import type { PacedPlayoutFrame } from "./paced-playout.js";
|
|
6
|
+
import { PacedPlayoutQueue } from "./paced-playout.js";
|
|
7
|
+
import { PlayoutProgressEmitter } from "./playout-progress.js";
|
|
8
|
+
import { closeWebSocketWithFallback } from "./websocket-close.js";
|
|
9
|
+
import { requireTtsAudioSampleRate } from "./transport-helpers.js";
|
|
10
|
+
|
|
11
|
+
export interface TelephonyOutboundCallbacks {
|
|
12
|
+
readonly carrierLabel: string;
|
|
13
|
+
getContextId(): string;
|
|
14
|
+
isActive(): boolean;
|
|
15
|
+
encodeFrames(audio: Uint8Array, sourceSampleRateHz: number, contextId: string): PacedPlayoutFrame[];
|
|
16
|
+
onInterrupt(contextId: string): void;
|
|
17
|
+
onDrain(contextId: string, playout: PacedPlayoutQueue, progress: PlayoutProgressEmitter): void;
|
|
18
|
+
onStop(reason: "send_buffer"): void;
|
|
19
|
+
onClear?(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TelephonyOutboundHandle {
|
|
23
|
+
clearPlayout(reason: string): void;
|
|
24
|
+
drainAndClose(socket: WebSocket, deadlineMs: number): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function wireTelephonyOutboundPipeline(args: {
|
|
28
|
+
readonly session: VoiceAgentSession;
|
|
29
|
+
readonly socket: WebSocket;
|
|
30
|
+
readonly disposers: Array<() => void>;
|
|
31
|
+
readonly outboundFrameDurationMs: number;
|
|
32
|
+
readonly maxQueuedOutputAudioMs: number;
|
|
33
|
+
readonly callbacks: TelephonyOutboundCallbacks;
|
|
34
|
+
}): TelephonyOutboundHandle {
|
|
35
|
+
const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks } = args;
|
|
36
|
+
|
|
37
|
+
const recordDiscardedPlayout = (discardedMs: number, reason: string): void => {
|
|
38
|
+
if (discardedMs <= 0) return;
|
|
39
|
+
session.bus.push(Route.Critical, {
|
|
40
|
+
kind: "record.assistant_audio",
|
|
41
|
+
contextId: callbacks.getContextId(),
|
|
42
|
+
timestampMs: Date.now(),
|
|
43
|
+
audio: new Uint8Array(0),
|
|
44
|
+
truncate: true,
|
|
45
|
+
});
|
|
46
|
+
session.bus.push(Route.Critical, {
|
|
47
|
+
kind: "metric.conversation",
|
|
48
|
+
contextId: callbacks.getContextId(),
|
|
49
|
+
timestampMs: Date.now(),
|
|
50
|
+
name: `${callbacks.carrierLabel}.${reason}_playout_cleared_ms`,
|
|
51
|
+
value: String(discardedMs),
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const playoutProgress = new PlayoutProgressEmitter(session.bus);
|
|
56
|
+
const playout = new PacedPlayoutQueue(
|
|
57
|
+
outboundFrameDurationMs,
|
|
58
|
+
maxQueuedOutputAudioMs,
|
|
59
|
+
(droppedMs) => {
|
|
60
|
+
// Non-fatal: the tail that did not fit was dropped; playout continues.
|
|
61
|
+
recordDiscardedPlayout(droppedMs, "overflow");
|
|
62
|
+
},
|
|
63
|
+
(discardedMs) => {
|
|
64
|
+
callbacks.onStop("send_buffer");
|
|
65
|
+
recordDiscardedPlayout(discardedMs, "send_buffer");
|
|
66
|
+
},
|
|
67
|
+
(lateMs) => {
|
|
68
|
+
session.bus.push(Route.Background, {
|
|
69
|
+
kind: "metric.conversation",
|
|
70
|
+
contextId: callbacks.getContextId(),
|
|
71
|
+
timestampMs: Date.now(),
|
|
72
|
+
name: `${callbacks.carrierLabel}.pacer_deadline_miss`,
|
|
73
|
+
value: String(lateMs),
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
playoutProgress.onFramePlayed,
|
|
77
|
+
);
|
|
78
|
+
const interruptedContextIds = new Set<string>();
|
|
79
|
+
|
|
80
|
+
disposers.push(
|
|
81
|
+
() => playout.close(),
|
|
82
|
+
session.bus.on("interrupt.tts", (pkt) => {
|
|
83
|
+
const interrupt = pkt as InterruptTtsPacket;
|
|
84
|
+
interruptedContextIds.add(interrupt.contextId);
|
|
85
|
+
playoutProgress.discard(interrupt.contextId);
|
|
86
|
+
playout.clearInterruptible();
|
|
87
|
+
callbacks.onClear?.();
|
|
88
|
+
callbacks.onInterrupt(interrupt.contextId);
|
|
89
|
+
session.bus.push(Route.Background, {
|
|
90
|
+
kind: "metric.conversation",
|
|
91
|
+
contextId: interrupt.contextId,
|
|
92
|
+
timestampMs: Date.now(),
|
|
93
|
+
name: `${callbacks.carrierLabel}.interrupt_onset_to_media_silent_ms`,
|
|
94
|
+
value: String(Math.max(0, Date.now() - interrupt.timestampMs)),
|
|
95
|
+
});
|
|
96
|
+
}),
|
|
97
|
+
session.bus.on("tts.audio", (pkt) => {
|
|
98
|
+
const audioPacket = pkt as TextToSpeechAudioPacket;
|
|
99
|
+
if (interruptedContextIds.has(audioPacket.contextId)) return;
|
|
100
|
+
if (!callbacks.isActive()) return;
|
|
101
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
102
|
+
session.bus.push(Route.Background, {
|
|
103
|
+
kind: "metric.conversation",
|
|
104
|
+
contextId: audioPacket.contextId,
|
|
105
|
+
timestampMs: Date.now(),
|
|
106
|
+
name: "websocket.send_after_close",
|
|
107
|
+
value: "1",
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const frames = callbacks.encodeFrames(
|
|
112
|
+
audioPacket.audio,
|
|
113
|
+
requireTtsAudioSampleRate(audioPacket.sampleRateHz),
|
|
114
|
+
audioPacket.contextId,
|
|
115
|
+
);
|
|
116
|
+
playout.enqueue(frames);
|
|
117
|
+
}),
|
|
118
|
+
session.bus.on("tts.end", (pkt) => {
|
|
119
|
+
const end = pkt as TextToSpeechEndPacket;
|
|
120
|
+
if (interruptedContextIds.has(end.contextId)) return;
|
|
121
|
+
if (!callbacks.isActive()) return;
|
|
122
|
+
callbacks.onDrain(end.contextId, playout, playoutProgress);
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
clearPlayout: (reason: string) => {
|
|
128
|
+
callbacks.onClear?.();
|
|
129
|
+
recordDiscardedPlayout(playout.clear(), reason);
|
|
130
|
+
},
|
|
131
|
+
drainAndClose: (socket: WebSocket, deadlineMs: number): Promise<void> => {
|
|
132
|
+
if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
}
|
|
135
|
+
return new Promise<void>((resolve) => {
|
|
136
|
+
let settled = false;
|
|
137
|
+
let deadlineTimer: ReturnType<typeof setTimeout>;
|
|
138
|
+
const settle = () => {
|
|
139
|
+
if (settled) return;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(deadlineTimer);
|
|
142
|
+
socket.off("close", onSocketClose);
|
|
143
|
+
resolve();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onSocketClose = (): void => {
|
|
147
|
+
settle();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
deadlineTimer = setTimeout(() => {
|
|
151
|
+
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
|
|
152
|
+
socket.terminate();
|
|
153
|
+
}
|
|
154
|
+
settle();
|
|
155
|
+
}, Math.max(0, deadlineMs - Date.now()));
|
|
156
|
+
(deadlineTimer as NodeJS.Timeout).unref?.();
|
|
157
|
+
socket.once("close", onSocketClose);
|
|
158
|
+
|
|
159
|
+
// enqueueControl may run synchronously when the queue is idle — register timers first.
|
|
160
|
+
playout.enqueueControl(() => {
|
|
161
|
+
closeWebSocketWithFallback(socket, 1001, "server going away");
|
|
162
|
+
settle();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { PacedPlayoutQueue, type PacedPlayoutFrame } from "./paced-playout.js";
|
|
5
|
+
|
|
6
|
+
function makeFrame(onSend?: () => void): PacedPlayoutFrame {
|
|
7
|
+
return { send: () => { onSend?.(); } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("PacedPlayoutQueue — drift-corrected scheduling", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("drift-locked cadence: second frame fires at T+FRAME_MS despite slow first send", () => {
|
|
20
|
+
vi.setSystemTime(0);
|
|
21
|
+
const FRAME_MS = 20;
|
|
22
|
+
const sendTimes: number[] = [];
|
|
23
|
+
|
|
24
|
+
const queue = new PacedPlayoutQueue(FRAME_MS, 10_000, () => {});
|
|
25
|
+
|
|
26
|
+
// First send() runs outside tick() context (maybePump → pump is called
|
|
27
|
+
// synchronously from enqueue). Safe to call setSystemTime here.
|
|
28
|
+
// Simulates 8ms of work, so without drift-correction the second timer
|
|
29
|
+
// would be scheduled at callAt = 0+8+20 = 28 instead of 20.
|
|
30
|
+
queue.enqueue([
|
|
31
|
+
{
|
|
32
|
+
send: () => {
|
|
33
|
+
sendTimes.push(Date.now());
|
|
34
|
+
vi.setSystemTime(8);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{ send: () => { sendTimes.push(Date.now()); } },
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// With drift-correction: delay = max(0, 20 - 8) = 12, callAt = 8+12 = 20
|
|
41
|
+
// Without drift-correction: delay = 20, callAt = 8+20 = 28
|
|
42
|
+
vi.advanceTimersByTime(20); // covers [8, 28] — fires callAt=20 but NOT callAt=28
|
|
43
|
+
|
|
44
|
+
expect(sendTimes).toHaveLength(2);
|
|
45
|
+
expect(sendTimes[0]).toBe(0);
|
|
46
|
+
expect(sendTimes[1]).toBe(20); // wall-clock-locked
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("fires onDeadlineMiss with correct lateMs when pump wakes up past tolerance", () => {
|
|
50
|
+
vi.setSystemTime(0);
|
|
51
|
+
const FRAME_MS = 20;
|
|
52
|
+
const misses: number[] = [];
|
|
53
|
+
|
|
54
|
+
const queue = new PacedPlayoutQueue(
|
|
55
|
+
FRAME_MS,
|
|
56
|
+
10_000,
|
|
57
|
+
() => {},
|
|
58
|
+
() => {},
|
|
59
|
+
(lateMs) => misses.push(lateMs),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// First send() advances the clock to 30ms (past the T+20 next deadline).
|
|
63
|
+
// This causes the next timer to be scheduled with delay=0, callAt=30.
|
|
64
|
+
// When that timer fires, now=30 and nextDeadlineMs=20, so lateMs=10 > 5.
|
|
65
|
+
queue.enqueue([
|
|
66
|
+
{
|
|
67
|
+
send: () => {
|
|
68
|
+
vi.setSystemTime(30);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
makeFrame(),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
vi.advanceTimersByTime(1); // fire the 0ms timer (callAt=30, advances 30→31)
|
|
75
|
+
|
|
76
|
+
expect(misses).toHaveLength(1);
|
|
77
|
+
expect(misses[0]).toBe(10); // 30 - 20 = 10ms late
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does NOT fire onDeadlineMiss when pump fires on time (within tolerance)", () => {
|
|
81
|
+
vi.setSystemTime(0);
|
|
82
|
+
const FRAME_MS = 20;
|
|
83
|
+
const misses: number[] = [];
|
|
84
|
+
|
|
85
|
+
const queue = new PacedPlayoutQueue(
|
|
86
|
+
FRAME_MS,
|
|
87
|
+
10_000,
|
|
88
|
+
() => {},
|
|
89
|
+
() => {},
|
|
90
|
+
(lateMs) => misses.push(lateMs),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
queue.enqueue([makeFrame(), makeFrame(), makeFrame()]);
|
|
94
|
+
|
|
95
|
+
// Exact FRAME_MS advances — timers fire precisely on their deadlines.
|
|
96
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
97
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
98
|
+
|
|
99
|
+
expect(misses).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resets deadline tracking after clear() so next burst starts fresh with no spurious misses", () => {
|
|
103
|
+
vi.setSystemTime(0);
|
|
104
|
+
const FRAME_MS = 20;
|
|
105
|
+
const misses: number[] = [];
|
|
106
|
+
|
|
107
|
+
const queue = new PacedPlayoutQueue(
|
|
108
|
+
FRAME_MS,
|
|
109
|
+
10_000,
|
|
110
|
+
() => {},
|
|
111
|
+
() => {},
|
|
112
|
+
(lateMs) => misses.push(lateMs),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
queue.enqueue([makeFrame(), makeFrame()]);
|
|
116
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
117
|
+
queue.clear();
|
|
118
|
+
|
|
119
|
+
// Simulate a 5 s inter-utterance gap. Without clear() resetting the
|
|
120
|
+
// deadline, the next frame would appear ~5000ms late and trigger a miss.
|
|
121
|
+
vi.advanceTimersByTime(5_000);
|
|
122
|
+
|
|
123
|
+
// New burst with no simulated send overhead.
|
|
124
|
+
queue.enqueue([makeFrame(), makeFrame()]);
|
|
125
|
+
|
|
126
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
127
|
+
|
|
128
|
+
// The first frame of a new burst sets the baseline (nextDeadlineMs was 0),
|
|
129
|
+
// and the second frame fires on schedule — neither should miss.
|
|
130
|
+
expect(misses).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("re-baselines after a natural drain (no clear) so an inter-sentence gap is not a false miss", () => {
|
|
134
|
+
vi.setSystemTime(0);
|
|
135
|
+
const FRAME_MS = 20;
|
|
136
|
+
const misses: number[] = [];
|
|
137
|
+
|
|
138
|
+
const queue = new PacedPlayoutQueue(
|
|
139
|
+
FRAME_MS,
|
|
140
|
+
10_000,
|
|
141
|
+
() => {},
|
|
142
|
+
() => {},
|
|
143
|
+
(lateMs) => misses.push(lateMs),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// First burst of two frames drains naturally — the queue empties with NO
|
|
147
|
+
// clear() call (clear() only runs on overflow/send-failure/interrupt).
|
|
148
|
+
queue.enqueue([makeFrame(), makeFrame()]);
|
|
149
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
150
|
+
|
|
151
|
+
// Natural inter-sentence gap: no audio, no clear().
|
|
152
|
+
vi.advanceTimersByTime(2_000);
|
|
153
|
+
|
|
154
|
+
// Next burst. Its first frame must re-baseline rather than read the stale
|
|
155
|
+
// pre-gap deadline as a ~2000ms miss.
|
|
156
|
+
queue.enqueue([makeFrame(), makeFrame()]);
|
|
157
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
158
|
+
|
|
159
|
+
expect(misses).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not advance the playout deadline by frameDurationMs after a 0 ms control frame", () => {
|
|
163
|
+
const FRAME_MS = 20;
|
|
164
|
+
let sends = 0;
|
|
165
|
+
const misses: number[] = [];
|
|
166
|
+
|
|
167
|
+
const queue = new PacedPlayoutQueue(
|
|
168
|
+
FRAME_MS,
|
|
169
|
+
10_000,
|
|
170
|
+
() => {},
|
|
171
|
+
() => {},
|
|
172
|
+
(lateMs) => misses.push(lateMs),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
queue.enqueue([{ send: () => { sends += 1; } }]);
|
|
176
|
+
expect(sends).toBe(1);
|
|
177
|
+
queue.enqueueControl(() => { sends += 1; });
|
|
178
|
+
queue.enqueue([{ send: () => { sends += 1; } }]);
|
|
179
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
180
|
+
expect(sends).toBe(3);
|
|
181
|
+
vi.advanceTimersByTime(FRAME_MS);
|
|
182
|
+
expect(sends).toBe(3);
|
|
183
|
+
expect(misses).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("backward-compatible: works without onDeadlineMiss callback", () => {
|
|
187
|
+
const queue = new PacedPlayoutQueue(20, 10_000, () => {});
|
|
188
|
+
expect(() => {
|
|
189
|
+
queue.enqueue([makeFrame(), makeFrame()]);
|
|
190
|
+
vi.advanceTimersByTime(40);
|
|
191
|
+
}).not.toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("clearInterruptible drops audio but retains an uninterruptible control frame", () => {
|
|
195
|
+
const FRAME_MS = 20;
|
|
196
|
+
const sends: string[] = [];
|
|
197
|
+
const queue = new PacedPlayoutQueue(FRAME_MS, 10_000, () => {});
|
|
198
|
+
|
|
199
|
+
// a1 sends synchronously on enqueue; a2 + a3 remain queued behind the pacer timer.
|
|
200
|
+
queue.enqueue([
|
|
201
|
+
{ send: () => { sends.push("a1"); } },
|
|
202
|
+
{ send: () => { sends.push("a2"); } },
|
|
203
|
+
{ send: () => { sends.push("a3"); } },
|
|
204
|
+
]);
|
|
205
|
+
expect(sends).toEqual(["a1"]);
|
|
206
|
+
|
|
207
|
+
queue.enqueueControl(() => { sends.push("ctrl"); }, { uninterruptible: true });
|
|
208
|
+
|
|
209
|
+
const discarded = queue.clearInterruptible();
|
|
210
|
+
|
|
211
|
+
// a2 + a3 (interruptible audio) dropped; the uninterruptible control survives and is pumped.
|
|
212
|
+
expect(discarded).toBe(2 * FRAME_MS);
|
|
213
|
+
expect(sends).toEqual(["a1", "ctrl"]);
|
|
214
|
+
|
|
215
|
+
// No dropped audio is ever replayed on later ticks.
|
|
216
|
+
vi.advanceTimersByTime(FRAME_MS * 5);
|
|
217
|
+
expect(sends).toEqual(["a1", "ctrl"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("clearInterruptible never replays dropped audio frames", () => {
|
|
221
|
+
const FRAME_MS = 20;
|
|
222
|
+
const sends: string[] = [];
|
|
223
|
+
const queue = new PacedPlayoutQueue(FRAME_MS, 10_000, () => {});
|
|
224
|
+
queue.enqueue([
|
|
225
|
+
{ send: () => { sends.push("a1"); } },
|
|
226
|
+
{ send: () => { sends.push("a2"); } },
|
|
227
|
+
{ send: () => { sends.push("a3"); } },
|
|
228
|
+
]);
|
|
229
|
+
queue.clearInterruptible();
|
|
230
|
+
vi.advanceTimersByTime(FRAME_MS * 5);
|
|
231
|
+
expect(sends).toEqual(["a1"]); // a2/a3 never sent — no speech replay after interrupt
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("clearInterruptible drops unmarked control frames (back-compat with clear)", () => {
|
|
235
|
+
const FRAME_MS = 20;
|
|
236
|
+
const sends: string[] = [];
|
|
237
|
+
const queue = new PacedPlayoutQueue(FRAME_MS, 10_000, () => {});
|
|
238
|
+
queue.enqueue([
|
|
239
|
+
{ send: () => { sends.push("a1"); } },
|
|
240
|
+
{ send: () => { sends.push("a2"); } },
|
|
241
|
+
]);
|
|
242
|
+
queue.enqueueControl(() => { sends.push("ctrl"); }); // default = interruptible
|
|
243
|
+
queue.clearInterruptible();
|
|
244
|
+
vi.advanceTimersByTime(FRAME_MS * 5);
|
|
245
|
+
expect(sends).toEqual(["a1"]); // a2 + unmarked control both dropped
|
|
246
|
+
});
|
|
247
|
+
});
|