@kuralle-syrinx/server-websocket 4.2.0 → 4.3.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 +6 -5
- package/src/carrier-commands.ts +332 -0
- package/src/edge-telnyx.ts +389 -0
- package/src/edge.ts +7 -2
- package/src/index.ts +25 -2
- package/src/telnyx-codec.ts +163 -0
- package/src/telnyx.ts +54 -59
- package/src/twilio.ts +23 -0
- package/src/wire-carrier-control.ts +179 -0
- package/src/admission-control.test.ts +0 -270
- package/src/background-audio.test.ts +0 -224
- package/src/browser-opus.test.ts +0 -41
- package/src/browser-pacing.test.ts +0 -440
- package/src/edge-twilio.test.ts +0 -293
- package/src/edge.test.ts +0 -591
- package/src/graceful-drain.test.ts +0 -306
- package/src/inbound-audio.test.ts +0 -58
- package/src/index.test.ts +0 -2074
- package/src/outbound-playout-pipeline.test.ts +0 -314
- package/src/paced-playout.test.ts +0 -247
- package/src/playout-progress.test.ts +0 -56
- package/src/session-store.test.ts +0 -274
- package/src/smartpbx.test.ts +0 -967
- package/src/telnyx.test.ts +0 -1457
- package/src/transport-host.test.ts +0 -51
- package/src/turn-metrics.test.ts +0 -327
- package/src/twilio-auth.test.ts +0 -36
- package/src/twilio.test.ts +0 -1275
- package/src/websocket-close.test.ts +0 -63
- package/src/websocket-lifecycle.test.ts +0 -49
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -7
|
@@ -1,314 +0,0 @@
|
|
|
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 { BackgroundAudioMixer } from "./background-audio.js";
|
|
9
|
-
import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation, type RotatableTurnContext } from "./outbound-playout-pipeline.js";
|
|
10
|
-
|
|
11
|
-
function createMockSocket(): WebSocket {
|
|
12
|
-
const emitter = new EventEmitter();
|
|
13
|
-
let readyState: number = WebSocket.OPEN;
|
|
14
|
-
return {
|
|
15
|
-
get readyState() {
|
|
16
|
-
return readyState;
|
|
17
|
-
},
|
|
18
|
-
close: () => {
|
|
19
|
-
readyState = WebSocket.CLOSED;
|
|
20
|
-
emitter.emit("close");
|
|
21
|
-
},
|
|
22
|
-
terminate: () => {
|
|
23
|
-
readyState = WebSocket.CLOSED;
|
|
24
|
-
emitter.emit("close");
|
|
25
|
-
},
|
|
26
|
-
once: (event: string, handler: () => void) => {
|
|
27
|
-
emitter.once(event, handler);
|
|
28
|
-
},
|
|
29
|
-
off: (event: string, handler: () => void) => {
|
|
30
|
-
emitter.off(event, handler);
|
|
31
|
-
},
|
|
32
|
-
} as WebSocket;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function wireTestPipeline(socket: WebSocket): {
|
|
36
|
-
readonly session: VoiceAgentSession;
|
|
37
|
-
readonly handle: ReturnType<typeof wireTelephonyOutboundPipeline>;
|
|
38
|
-
readonly disposers: Array<() => void>;
|
|
39
|
-
} {
|
|
40
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
41
|
-
void session.start();
|
|
42
|
-
const disposers: Array<() => void> = [];
|
|
43
|
-
const handle = wireTelephonyOutboundPipeline({
|
|
44
|
-
session,
|
|
45
|
-
socket,
|
|
46
|
-
disposers,
|
|
47
|
-
outboundFrameDurationMs: 20,
|
|
48
|
-
maxQueuedOutputAudioMs: 30_000,
|
|
49
|
-
callbacks: {
|
|
50
|
-
carrierLabel: "test",
|
|
51
|
-
getContextId: () => "turn-drain",
|
|
52
|
-
isActive: () => true,
|
|
53
|
-
encodeFrames: (audio) => [{
|
|
54
|
-
contextId: "turn-drain",
|
|
55
|
-
send: () => true,
|
|
56
|
-
}],
|
|
57
|
-
onInterrupt: () => undefined,
|
|
58
|
-
onDrain: () => undefined,
|
|
59
|
-
onStop: () => undefined,
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
return { session, handle, disposers };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function makeFrames(count: number): Array<{ contextId: string; send: () => boolean }> {
|
|
66
|
-
return Array.from({ length: count }, () => ({
|
|
67
|
-
contextId: "turn-drain",
|
|
68
|
-
send: () => true,
|
|
69
|
-
}));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
describe("wireTelephonyOutboundPipeline.interrupt.tts", () => {
|
|
73
|
-
it("clears playout and emits interrupt_onset_to_media_silent_ms", async () => {
|
|
74
|
-
const socket = createMockSocket();
|
|
75
|
-
const { session, disposers } = wireTestPipeline(socket);
|
|
76
|
-
const mediaSilentMetrics: Array<{ name: string; value: string }> = [];
|
|
77
|
-
|
|
78
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
79
|
-
const metric = pkt as unknown as { name: string; value: string };
|
|
80
|
-
if (metric.name === "test.interrupt_onset_to_media_silent_ms") mediaSilentMetrics.push(metric);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const onset = Date.now() - 25;
|
|
84
|
-
session.bus.push(Route.Critical, {
|
|
85
|
-
kind: "interrupt.tts",
|
|
86
|
-
contextId: "turn-drain",
|
|
87
|
-
timestampMs: onset,
|
|
88
|
-
});
|
|
89
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
90
|
-
|
|
91
|
-
expect(mediaSilentMetrics).toEqual([
|
|
92
|
-
expect.objectContaining({
|
|
93
|
-
name: "test.interrupt_onset_to_media_silent_ms",
|
|
94
|
-
value: expect.stringMatching(/^\d+$/),
|
|
95
|
-
}),
|
|
96
|
-
]);
|
|
97
|
-
expect(Number(mediaSilentMetrics[0]!.value)).toBeGreaterThanOrEqual(0);
|
|
98
|
-
|
|
99
|
-
for (const dispose of disposers) dispose();
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("wireTelephonyOutboundPipeline overflow", () => {
|
|
104
|
-
it("drops the overflow tail at the cap without stopping playout or closing the socket", async () => {
|
|
105
|
-
const socket = createMockSocket();
|
|
106
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
107
|
-
await session.start();
|
|
108
|
-
const disposers: Array<() => void> = [];
|
|
109
|
-
const stops: string[] = [];
|
|
110
|
-
const discardedMetrics: Array<{ name: string; value: string }> = [];
|
|
111
|
-
let encodeCall = 0;
|
|
112
|
-
|
|
113
|
-
wireTelephonyOutboundPipeline({
|
|
114
|
-
session,
|
|
115
|
-
socket,
|
|
116
|
-
disposers,
|
|
117
|
-
outboundFrameDurationMs: 20,
|
|
118
|
-
maxQueuedOutputAudioMs: 200,
|
|
119
|
-
callbacks: {
|
|
120
|
-
carrierLabel: "test",
|
|
121
|
-
getContextId: () => "turn-drain",
|
|
122
|
-
isActive: () => true,
|
|
123
|
-
encodeFrames: () => {
|
|
124
|
-
encodeCall += 1;
|
|
125
|
-
return makeFrames(encodeCall === 1 ? 10 : 2);
|
|
126
|
-
},
|
|
127
|
-
onInterrupt: () => undefined,
|
|
128
|
-
onDrain: () => undefined,
|
|
129
|
-
onStop: (reason) => {
|
|
130
|
-
stops.push(reason);
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
session.bus.on("metric.conversation", (pkt) => {
|
|
135
|
-
const metric = pkt as unknown as { name: string; value: string };
|
|
136
|
-
if (metric.name === "test.overflow_playout_cleared_ms") {
|
|
137
|
-
discardedMetrics.push(metric);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
session.bus.push(Route.Main, {
|
|
142
|
-
kind: "tts.audio",
|
|
143
|
-
contextId: "turn-drain",
|
|
144
|
-
timestampMs: Date.now(),
|
|
145
|
-
audio: new Uint8Array([1, 2, 3, 4]),
|
|
146
|
-
sampleRateHz: 8000,
|
|
147
|
-
});
|
|
148
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
149
|
-
session.bus.push(Route.Main, {
|
|
150
|
-
kind: "tts.audio",
|
|
151
|
-
contextId: "turn-drain",
|
|
152
|
-
timestampMs: Date.now(),
|
|
153
|
-
audio: new Uint8Array([5, 6, 7, 8]),
|
|
154
|
-
sampleRateHz: 8000,
|
|
155
|
-
});
|
|
156
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
157
|
-
|
|
158
|
-
expect(stops).toEqual([]); // overflow is non-fatal — playout continues
|
|
159
|
-
expect(discardedMetrics).toEqual([
|
|
160
|
-
expect.objectContaining({
|
|
161
|
-
name: "test.overflow_playout_cleared_ms",
|
|
162
|
-
value: expect.stringMatching(/^\d+$/),
|
|
163
|
-
}),
|
|
164
|
-
]);
|
|
165
|
-
expect(Number(discardedMetrics[0]!.value)).toBeGreaterThan(0);
|
|
166
|
-
expect(socket.readyState).toBe(WebSocket.OPEN);
|
|
167
|
-
|
|
168
|
-
for (const dispose of disposers) dispose();
|
|
169
|
-
await session.close();
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe("wireTelephonyOutboundPipeline.drainAndClose", () => {
|
|
174
|
-
it("resolves immediately when the playout queue is idle", async () => {
|
|
175
|
-
const socket = createMockSocket();
|
|
176
|
-
const { handle, disposers } = wireTestPipeline(socket);
|
|
177
|
-
|
|
178
|
-
const startedAt = Date.now();
|
|
179
|
-
await handle.drainAndClose(socket, Date.now() + 30_000);
|
|
180
|
-
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
181
|
-
|
|
182
|
-
for (const dispose of disposers) dispose();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("settles promptly when the socket closes before queued playout control runs", async () => {
|
|
186
|
-
const socket = createMockSocket();
|
|
187
|
-
const { session, handle, disposers } = wireTestPipeline(socket);
|
|
188
|
-
|
|
189
|
-
const longAudio = pcm16SamplesToBytes(new Int16Array(8000 * 2));
|
|
190
|
-
session.bus.push(Route.Main, {
|
|
191
|
-
kind: "tts.audio",
|
|
192
|
-
contextId: "turn-drain",
|
|
193
|
-
timestampMs: Date.now(),
|
|
194
|
-
audio: longAudio,
|
|
195
|
-
sampleRateHz: 8000,
|
|
196
|
-
});
|
|
197
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
198
|
-
|
|
199
|
-
const startedAt = Date.now();
|
|
200
|
-
const drainPromise = handle.drainAndClose(socket, Date.now() + 30_000);
|
|
201
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
202
|
-
socket.close();
|
|
203
|
-
await drainPromise;
|
|
204
|
-
|
|
205
|
-
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
206
|
-
|
|
207
|
-
for (const dispose of disposers) dispose();
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
describe("installTelephonyTurnRotation", () => {
|
|
212
|
-
// Regression for the telephony "deaf after turn 1" P0: a carrier gives one
|
|
213
|
-
// stream per call, but STT/TTS retire a contextId once its turn completes, so
|
|
214
|
-
// reusing a single callSid id muted the agent after turn 1. Every turn must get
|
|
215
|
-
// a fresh per-turn id with a turn.change boundary.
|
|
216
|
-
it("rotates a per-turn contextId and emits turn.change on each completed turn", async () => {
|
|
217
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
218
|
-
void session.start();
|
|
219
|
-
const disposers: Array<() => void> = [];
|
|
220
|
-
const state: RotatableTurnContext = {
|
|
221
|
-
contextId: "twilio-CA123",
|
|
222
|
-
contextBase: "twilio-CA123",
|
|
223
|
-
turnCounter: 0,
|
|
224
|
-
};
|
|
225
|
-
const turnChanges: Array<{ contextId: string; previousContextId: string; reason: string }> = [];
|
|
226
|
-
session.bus.on("turn.change", (pkt) => {
|
|
227
|
-
const change = pkt as unknown as { contextId: string; previousContextId: string; reason: string };
|
|
228
|
-
turnChanges.push({ contextId: change.contextId, previousContextId: change.previousContextId, reason: change.reason });
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
installTelephonyTurnRotation(session, disposers, state);
|
|
232
|
-
|
|
233
|
-
// Turn 1 runs on the base id.
|
|
234
|
-
expect(state.contextId).toBe("twilio-CA123");
|
|
235
|
-
|
|
236
|
-
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123", timestampMs: Date.now(), text: "one", transcripts: [] });
|
|
237
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
238
|
-
expect(state.contextId).toBe("twilio-CA123-t1");
|
|
239
|
-
|
|
240
|
-
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123-t1", timestampMs: Date.now(), text: "two", transcripts: [] });
|
|
241
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
242
|
-
expect(state.contextId).toBe("twilio-CA123-t2");
|
|
243
|
-
|
|
244
|
-
expect(turnChanges).toEqual([
|
|
245
|
-
{ contextId: "twilio-CA123-t1", previousContextId: "twilio-CA123", reason: "telephony_turn_complete" },
|
|
246
|
-
{ contextId: "twilio-CA123-t2", previousContextId: "twilio-CA123-t1", reason: "telephony_turn_complete" },
|
|
247
|
-
]);
|
|
248
|
-
|
|
249
|
-
for (const dispose of disposers) dispose();
|
|
250
|
-
await session.close();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("no-ops until a base is set", async () => {
|
|
254
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
255
|
-
void session.start();
|
|
256
|
-
const disposers: Array<() => void> = [];
|
|
257
|
-
const state: RotatableTurnContext = { contextId: "", contextBase: "", turnCounter: 0 };
|
|
258
|
-
installTelephonyTurnRotation(session, disposers, state);
|
|
259
|
-
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "", timestampMs: Date.now(), text: "", transcripts: [] });
|
|
260
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
261
|
-
expect(state.contextId).toBe("");
|
|
262
|
-
expect(state.turnCounter).toBe(0);
|
|
263
|
-
for (const dispose of disposers) dispose();
|
|
264
|
-
await session.close();
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it("mixes the background bed into speech before per-carrier encoding", async () => {
|
|
268
|
-
const socket = createMockSocket();
|
|
269
|
-
const session = new VoiceAgentSession({ plugins: {} });
|
|
270
|
-
void session.start();
|
|
271
|
-
const disposers: Array<() => void> = [];
|
|
272
|
-
const encodedChunks: Int16Array[] = [];
|
|
273
|
-
wireTelephonyOutboundPipeline({
|
|
274
|
-
session,
|
|
275
|
-
socket,
|
|
276
|
-
disposers,
|
|
277
|
-
outboundFrameDurationMs: 20,
|
|
278
|
-
maxQueuedOutputAudioMs: 30_000,
|
|
279
|
-
backgroundAudio: new BackgroundAudioMixer({
|
|
280
|
-
ambient: { pcm: new Int16Array(160).fill(1000), sampleRateHz: 16000, gain: 0.5 },
|
|
281
|
-
duckWhileSpeaking: 0.5,
|
|
282
|
-
fadeMs: 0,
|
|
283
|
-
}),
|
|
284
|
-
callbacks: {
|
|
285
|
-
carrierLabel: "test",
|
|
286
|
-
getContextId: () => "turn-mix",
|
|
287
|
-
isActive: () => true,
|
|
288
|
-
encodeFrames: (audio) => {
|
|
289
|
-
encodedChunks.push(new Int16Array(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength)));
|
|
290
|
-
return [{ contextId: "turn-mix", send: () => true }];
|
|
291
|
-
},
|
|
292
|
-
onInterrupt: () => undefined,
|
|
293
|
-
onDrain: () => undefined,
|
|
294
|
-
onStop: () => undefined,
|
|
295
|
-
},
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
session.bus.push(Route.Main, {
|
|
299
|
-
kind: "tts.audio",
|
|
300
|
-
contextId: "turn-mix",
|
|
301
|
-
timestampMs: Date.now(),
|
|
302
|
-
audio: pcm16SamplesToBytes(new Int16Array(4)), // silence in → ambient-only out
|
|
303
|
-
sampleRateHz: 16000,
|
|
304
|
-
});
|
|
305
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
306
|
-
|
|
307
|
-
expect(encodedChunks).toHaveLength(1);
|
|
308
|
-
// ambient 1000 × gain 0.5 × duck 0.5 = 250 layered under the (silent) speech
|
|
309
|
-
expect(Array.from(encodedChunks[0]!)).toEqual([250, 250, 250, 250]);
|
|
310
|
-
|
|
311
|
-
for (const dispose of disposers) dispose();
|
|
312
|
-
await session.close();
|
|
313
|
-
});
|
|
314
|
-
});
|
|
@@ -1,247 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { PipelineBusImpl } from "@kuralle-syrinx/core";
|
|
5
|
-
import { PlayoutProgressEmitter } from "./playout-progress.js";
|
|
6
|
-
|
|
7
|
-
async function withRunningBus(fn: (bus: PipelineBusImpl) => void | Promise<void>): Promise<void> {
|
|
8
|
-
const bus = new PipelineBusImpl();
|
|
9
|
-
const startP = bus.start();
|
|
10
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
11
|
-
await fn(bus);
|
|
12
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
13
|
-
bus.stop();
|
|
14
|
-
await startP;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe("PlayoutProgressEmitter", () => {
|
|
18
|
-
it("emits playout_started on the first paced audio frame and throttles progress", async () => {
|
|
19
|
-
const startedAt: number[] = [];
|
|
20
|
-
const progress: number[] = [];
|
|
21
|
-
|
|
22
|
-
await withRunningBus((bus) => {
|
|
23
|
-
bus.on("tts.playout_started", (pkt) => {
|
|
24
|
-
startedAt.push((pkt as { timestampMs: number }).timestampMs);
|
|
25
|
-
});
|
|
26
|
-
bus.on("tts.playout_progress", (pkt) => {
|
|
27
|
-
progress.push((pkt as unknown as { playedOutMs: number }).playedOutMs);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const emitter = new PlayoutProgressEmitter(bus);
|
|
31
|
-
emitter.onFramePlayed("turn-a", 20);
|
|
32
|
-
emitter.onFramePlayed("turn-a", 20);
|
|
33
|
-
emitter.onFramePlayed("turn-a", 200);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
expect(startedAt).toHaveLength(1);
|
|
37
|
-
expect(progress).toEqual([240]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("re-emits playout_started after discard resets context tracking", async () => {
|
|
41
|
-
let startedCount = 0;
|
|
42
|
-
|
|
43
|
-
await withRunningBus((bus) => {
|
|
44
|
-
bus.on("tts.playout_started", () => {
|
|
45
|
-
startedCount += 1;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const emitter = new PlayoutProgressEmitter(bus);
|
|
49
|
-
emitter.onFramePlayed("turn-a", 20);
|
|
50
|
-
emitter.discard("turn-a");
|
|
51
|
-
emitter.onFramePlayed("turn-a", 20);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
expect(startedCount).toBe(2);
|
|
55
|
-
});
|
|
56
|
-
});
|