@kuralle-syrinx/core 4.1.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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +22 -3
  3. package/src/audio/alaw.ts +56 -0
  4. package/src/audio/g722.ts +329 -0
  5. package/src/audio/index.ts +12 -0
  6. package/src/audio/loudness.ts +87 -0
  7. package/src/confidence-to-wait.ts +30 -0
  8. package/src/idle-timeout.ts +1 -0
  9. package/src/incremental-unit.ts +19 -0
  10. package/src/index.ts +101 -1
  11. package/src/interaction-coordinator.ts +240 -0
  12. package/src/interaction-policy.ts +116 -0
  13. package/src/iu-ledger.ts +110 -0
  14. package/src/observability-observer.ts +8 -4
  15. package/src/observability.ts +30 -1
  16. package/src/packet-factories.ts +137 -2
  17. package/src/packets.ts +140 -4
  18. package/src/plugin-contract.ts +41 -0
  19. package/src/policies/defer.ts +15 -0
  20. package/src/policies/rule-based.ts +155 -0
  21. package/src/pricing.ts +137 -0
  22. package/src/primary-speaker-gate.ts +23 -3
  23. package/src/reasoner-hedge.ts +216 -0
  24. package/src/reasoner-route.ts +113 -0
  25. package/src/reasoner.ts +28 -1
  26. package/src/spend-cap.ts +52 -0
  27. package/src/turn-arbiter.ts +45 -4
  28. package/src/voice-agent-session.ts +682 -53
  29. package/src/voice-text.ts +69 -0
  30. package/src/audio/audio.test.ts +0 -285
  31. package/src/audio-envelope.test.ts +0 -167
  32. package/src/error-handler.test.ts +0 -56
  33. package/src/latency-filler.test.ts +0 -62
  34. package/src/observability-observer.test.ts +0 -245
  35. package/src/observability.test.ts +0 -85
  36. package/src/packet-factories.test.ts +0 -34
  37. package/src/pipeline-bus.g10.test.ts +0 -145
  38. package/src/pipeline-bus.test.ts +0 -211
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/tts-playout-clock.test.ts +0 -125
  44. package/src/turn-arbiter.characterization.test.ts +0 -477
  45. package/src/turn-arbiter.test.ts +0 -567
  46. package/src/voice-agent-session.test.ts +0 -2879
  47. package/src/voice-text.test.ts +0 -94
  48. package/tsconfig.json +0 -21
@@ -1,125 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
- import { TtsPlayoutClock } from "./tts-playout-clock.js";
5
-
6
- describe("TtsPlayoutClock", () => {
7
- beforeEach(() => {
8
- vi.useFakeTimers();
9
- vi.setSystemTime(0);
10
- });
11
- afterEach(() => {
12
- vi.useRealTimers();
13
- });
14
-
15
- it("marks a context active on the first audio chunk", () => {
16
- const clock = new TtsPlayoutClock();
17
- expect(clock.isActive("c1")).toBe(false);
18
- clock.noteAudio("c1", 100, Date.now());
19
- expect(clock.isActive("c1")).toBe(true);
20
- expect(clock.latestActive()).toBe("c1");
21
- });
22
-
23
- it("keeps a context interruptible until its playout estimate elapses, then releases", () => {
24
- const clock = new TtsPlayoutClock();
25
- clock.noteAudio("c1", 200, Date.now()); // playout ends at t=200
26
- clock.scheduleRelease("c1", Date.now()); // 200ms remaining
27
- expect(clock.isActive("c1")).toBe(true);
28
- vi.advanceTimersByTime(199);
29
- expect(clock.isActive("c1")).toBe(true);
30
- vi.advanceTimersByTime(1);
31
- expect(clock.isActive("c1")).toBe(false);
32
- });
33
-
34
- it("releases immediately when the playout estimate has already passed", () => {
35
- const clock = new TtsPlayoutClock();
36
- clock.noteAudio("c1", 50, Date.now());
37
- vi.setSystemTime(100); // well past the 50ms estimate
38
- clock.scheduleRelease("c1", Date.now());
39
- expect(clock.isActive("c1")).toBe(false);
40
- });
41
-
42
- it("lets real transport playout override the estimate: the timer must not pre-empt", () => {
43
- const clock = new TtsPlayoutClock();
44
- clock.noteAudio("c1", 100, Date.now());
45
- clock.scheduleRelease("c1", Date.now()); // estimate release at t=100
46
- clock.noteProgress("c1", false); // transport now authoritative, not yet complete
47
- vi.advanceTimersByTime(500); // estimate timer fires but must defer
48
- expect(clock.isActive("c1")).toBe(true);
49
- clock.noteProgress("c1", true); // transport says done
50
- expect(clock.isActive("c1")).toBe(false);
51
- });
52
-
53
- it("advances the playout cursor across chunks instead of resetting it", () => {
54
- const clock = new TtsPlayoutClock();
55
- clock.noteAudio("c1", 100, Date.now()); // ends at 100
56
- clock.noteAudio("c1", 100, Date.now()); // still t=0, extends to 200
57
- clock.scheduleRelease("c1", Date.now());
58
- vi.advanceTimersByTime(150);
59
- expect(clock.isActive("c1")).toBe(true); // would have released at 100 if reset
60
- vi.advanceTimersByTime(50);
61
- expect(clock.isActive("c1")).toBe(false);
62
- });
63
-
64
- it("reports the most-recently-added active context as latest", () => {
65
- const clock = new TtsPlayoutClock();
66
- clock.noteAudio("a", 10, Date.now());
67
- clock.noteAudio("b", 10, Date.now());
68
- expect(clock.latestActive()).toBe("b");
69
- clock.release("b");
70
- expect(clock.latestActive()).toBe("a");
71
- clock.release("a");
72
- expect(clock.latestActive()).toBe("");
73
- });
74
-
75
- it("records playout position and returns it via positionMs", () => {
76
- const clock = new TtsPlayoutClock();
77
- expect(clock.positionMs("c1")).toBeUndefined();
78
- clock.noteAudio("c1", 100, Date.now());
79
- clock.noteProgress("c1", false, 42);
80
- expect(clock.positionMs("c1")).toBe(42);
81
- });
82
-
83
- it("keeps the latest playout position across multiple progress updates", () => {
84
- const clock = new TtsPlayoutClock();
85
- clock.noteAudio("c1", 100, Date.now());
86
- clock.noteProgress("c1", false, 10);
87
- clock.noteProgress("c1", false, 55);
88
- expect(clock.positionMs("c1")).toBe(55);
89
- });
90
-
91
- it("drops recorded position on release and clear", () => {
92
- const clock = new TtsPlayoutClock();
93
- clock.noteAudio("c1", 100, Date.now());
94
- clock.noteProgress("c1", false, 30);
95
- clock.release("c1");
96
- expect(clock.positionMs("c1")).toBeUndefined();
97
-
98
- clock.noteAudio("c2", 100, Date.now());
99
- clock.noteProgress("c2", false, 20);
100
- clock.clear();
101
- expect(clock.positionMs("c2")).toBeUndefined();
102
- });
103
-
104
- it("noteProgress without position still releases on complete", () => {
105
- const clock = new TtsPlayoutClock();
106
- clock.noteAudio("c1", 100, Date.now());
107
- clock.noteProgress("c1", false);
108
- expect(clock.positionMs("c1")).toBeUndefined();
109
- expect(clock.isActive("c1")).toBe(true);
110
- clock.noteProgress("c1", true);
111
- expect(clock.isActive("c1")).toBe(false);
112
- expect(clock.positionMs("c1")).toBeUndefined();
113
- });
114
-
115
- it("clear() drops all state and cancels pending release timers", () => {
116
- const clock = new TtsPlayoutClock();
117
- clock.noteAudio("c1", 100, Date.now());
118
- clock.scheduleRelease("c1", Date.now());
119
- clock.clear();
120
- expect(clock.isActive("c1")).toBe(false);
121
- // A leaked timer would throw "release of cleared context" — assert none fire.
122
- expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
123
- expect(clock.latestActive()).toBe("");
124
- });
125
- });
@@ -1,477 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- //
3
- // CR-09 Stage 0 — pins the implicit barge-in / turn-taking transition table on the
4
- // session before extraction. Values are observed from driving the real session.
5
-
6
- import { describe, it, expect } from "vitest";
7
- import { VoiceAgentSession } from "./voice-agent-session.js";
8
- import { Route } from "./index.js";
9
- import type {
10
- InterruptTtsPacket,
11
- TextToSpeechAudioPacket,
12
- TextToSpeechEndPacket,
13
- VadAudioPacket,
14
- VadSpeechActivityPacket,
15
- VadSpeechEndedPacket,
16
- VadSpeechStartedPacket,
17
- } from "./packets.js";
18
- import {
19
- BYSTANDER_SPEAKER_TONE_HZ,
20
- PRIMARY_SPEAKER_TONE_HZ,
21
- synthesizeTonePcm16,
22
- } from "./primary-speaker-fixtures.js";
23
-
24
- async function closeSession(session: VoiceAgentSession): Promise<void> {
25
- if (session.state !== "closed") {
26
- await session.close();
27
- }
28
- }
29
-
30
- async function enrollPrimarySpeaker(
31
- session: VoiceAgentSession,
32
- contextId = "user-enroll",
33
- ): Promise<void> {
34
- const chunk = synthesizeTonePcm16({
35
- frequencyHz: PRIMARY_SPEAKER_TONE_HZ,
36
- durationMs: 32,
37
- });
38
- const t0 = Date.now();
39
- session.bus.push(Route.Main, {
40
- kind: "vad.speech_started",
41
- contextId,
42
- timestampMs: t0,
43
- confidence: 0.99,
44
- } satisfies VadSpeechStartedPacket);
45
- for (let i = 0; i < 12; i += 1) {
46
- session.bus.push(Route.Main, {
47
- kind: "user.audio_received",
48
- contextId,
49
- timestampMs: t0 + i * 20,
50
- audio: chunk,
51
- });
52
- }
53
- session.bus.push(Route.Main, {
54
- kind: "vad.speech_ended",
55
- contextId,
56
- timestampMs: t0 + 300,
57
- } satisfies VadSpeechEndedPacket);
58
- await new Promise((resolve) => setTimeout(resolve, 10));
59
- }
60
-
61
- function armAssistantSpeaking(session: VoiceAgentSession, contextId = "assistant-turn"): void {
62
- session.bus.push(Route.Main, {
63
- kind: "tts.audio",
64
- contextId,
65
- timestampMs: Date.now(),
66
- audio: new Uint8Array([1, 2, 3, 4]),
67
- sampleRateHz: 16000,
68
- } satisfies TextToSpeechAudioPacket);
69
- }
70
-
71
- function collectMetrics(session: VoiceAgentSession): {
72
- names: string[];
73
- pairs: Array<{ name: string; value: string }>;
74
- } {
75
- const names: string[] = [];
76
- const pairs: Array<{ name: string; value: string }> = [];
77
- session.bus.on("metric.conversation", (pkt) => {
78
- const m = pkt as unknown as { name: string; value: string };
79
- names.push(m.name);
80
- pairs.push({ name: m.name, value: m.value });
81
- });
82
- return { names, pairs };
83
- }
84
-
85
- describe("turn-taking transition table (CR-09 characterization)", () => {
86
- it("suppresses short speech blip via interrupt.suppressed_short_speech", async () => {
87
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
88
- const interrupts: InterruptTtsPacket[] = [];
89
- const { names: metrics } = collectMetrics(session);
90
-
91
- await session.start();
92
- session.bus.on("interrupt.tts", (pkt) => {
93
- interrupts.push(pkt as InterruptTtsPacket);
94
- });
95
- armAssistantSpeaking(session);
96
- await new Promise((resolve) => setTimeout(resolve, 10));
97
-
98
- const t0 = 1000;
99
- session.bus.push(Route.Main, {
100
- kind: "vad.speech_started",
101
- contextId: "user",
102
- timestampMs: t0,
103
- confidence: 0.99,
104
- } satisfies VadSpeechStartedPacket);
105
- session.bus.push(Route.Main, {
106
- kind: "vad.speech_activity",
107
- contextId: "user",
108
- timestampMs: t0 + 90,
109
- isAsync: true,
110
- } satisfies VadSpeechActivityPacket);
111
- session.bus.push(Route.Main, {
112
- kind: "vad.speech_ended",
113
- contextId: "user",
114
- timestampMs: t0 + 130,
115
- } satisfies VadSpeechEndedPacket);
116
- await new Promise((resolve) => setTimeout(resolve, 30));
117
-
118
- expect(interrupts).toEqual([]);
119
- expect(metrics).toContain("interrupt.suppressed_short_speech");
120
-
121
- await closeSession(session);
122
- });
123
-
124
- it("commits sustained speech via interrupt.committed_after_ms and interrupt.detected", async () => {
125
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
126
- const interrupts: InterruptTtsPacket[] = [];
127
- const { names: metrics, pairs } = collectMetrics(session);
128
-
129
- await session.start();
130
- session.bus.on("interrupt.tts", (pkt) => {
131
- interrupts.push(pkt as InterruptTtsPacket);
132
- });
133
- armAssistantSpeaking(session);
134
- await new Promise((resolve) => setTimeout(resolve, 10));
135
-
136
- const t0 = 2000;
137
- session.bus.push(Route.Main, {
138
- kind: "vad.speech_started",
139
- contextId: "user",
140
- timestampMs: t0,
141
- confidence: 0.99,
142
- } satisfies VadSpeechStartedPacket);
143
- session.bus.push(Route.Main, {
144
- kind: "vad.speech_activity",
145
- contextId: "user",
146
- timestampMs: t0 + 300,
147
- isAsync: true,
148
- } satisfies VadSpeechActivityPacket);
149
- await new Promise((resolve) => setTimeout(resolve, 20));
150
-
151
- expect(interrupts).toEqual([
152
- expect.objectContaining({ kind: "interrupt.tts", contextId: "assistant-turn" }),
153
- ]);
154
- expect(metrics).toContain("interrupt.committed_after_ms");
155
- expect(metrics).toContain("vaqi.interruption");
156
- expect(metrics).toContain("interrupt.latency_ms");
157
- expect(pairs).toContainEqual({ name: "interrupt.latency_ms", value: "300" });
158
-
159
- await closeSession(session);
160
- });
161
-
162
- it("suppresses non-primary speaker via interrupt.suppressed_non_primary", async () => {
163
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
164
- const interrupts: InterruptTtsPacket[] = [];
165
- const { names: metrics } = collectMetrics(session);
166
-
167
- await session.start();
168
- session.bus.on("interrupt.tts", (pkt) => {
169
- interrupts.push(pkt as InterruptTtsPacket);
170
- });
171
- await enrollPrimarySpeaker(session);
172
- armAssistantSpeaking(session);
173
- await new Promise((resolve) => setTimeout(resolve, 10));
174
-
175
- const bystander = synthesizeTonePcm16({
176
- frequencyHz: BYSTANDER_SPEAKER_TONE_HZ,
177
- durationMs: 32,
178
- });
179
- const t0 = 3000;
180
- session.bus.push(Route.Main, {
181
- kind: "vad.speech_started",
182
- contextId: "user-barge",
183
- timestampMs: t0,
184
- confidence: 0.99,
185
- } satisfies VadSpeechStartedPacket);
186
- for (let i = 0; i < 10; i += 1) {
187
- session.bus.push(Route.Main, {
188
- kind: "vad.audio",
189
- contextId: "user-barge",
190
- timestampMs: t0 + 20 + i * 30,
191
- audio: bystander,
192
- } satisfies VadAudioPacket);
193
- }
194
- session.bus.push(Route.Main, {
195
- kind: "vad.speech_activity",
196
- contextId: "user-barge",
197
- timestampMs: t0 + 320,
198
- isAsync: true,
199
- } satisfies VadSpeechActivityPacket);
200
- await new Promise((resolve) => setTimeout(resolve, 20));
201
-
202
- expect(interrupts).toEqual([]);
203
- expect(metrics).toContain("interrupt.suppressed_non_primary");
204
-
205
- await closeSession(session);
206
- });
207
-
208
- it("cuts immediately when minInterruptionMs is 0 and no speaker profile", async () => {
209
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 0 });
210
- const interrupts: InterruptTtsPacket[] = [];
211
- const { pairs } = collectMetrics(session);
212
-
213
- await session.start();
214
- session.bus.on("interrupt.tts", (pkt) => {
215
- interrupts.push(pkt as InterruptTtsPacket);
216
- });
217
- armAssistantSpeaking(session);
218
- await new Promise((resolve) => setTimeout(resolve, 10));
219
-
220
- session.bus.push(Route.Main, {
221
- kind: "vad.speech_started",
222
- contextId: "user",
223
- timestampMs: 4000,
224
- confidence: 0.99,
225
- } satisfies VadSpeechStartedPacket);
226
- await new Promise((resolve) => setTimeout(resolve, 20));
227
-
228
- expect(interrupts).toEqual([
229
- expect.objectContaining({ kind: "interrupt.tts", contextId: "assistant-turn" }),
230
- ]);
231
- expect(pairs).toEqual([
232
- { name: "vaqi.interruption", value: "1" },
233
- { name: "interrupt.onset_to_logic_cancel_ms", value: expect.stringMatching(/^\d+$/) },
234
- ]);
235
-
236
- await closeSession(session);
237
- });
238
-
239
- it("resolves without cut when playout ended via interrupt.gate_resolved_after_tts_end", async () => {
240
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
241
- const interrupts: InterruptTtsPacket[] = [];
242
- const { names: metrics } = collectMetrics(session);
243
-
244
- await session.start();
245
- session.bus.on("interrupt.tts", (pkt) => {
246
- interrupts.push(pkt as InterruptTtsPacket);
247
- });
248
- armAssistantSpeaking(session);
249
- await new Promise((resolve) => setTimeout(resolve, 10));
250
-
251
- const t0 = 5000;
252
- session.bus.push(Route.Main, {
253
- kind: "vad.speech_started",
254
- contextId: "user",
255
- timestampMs: t0,
256
- confidence: 0.99,
257
- } satisfies VadSpeechStartedPacket);
258
- session.bus.push(Route.Main, {
259
- kind: "tts.end",
260
- contextId: "assistant-turn",
261
- timestampMs: t0 + 50,
262
- } satisfies TextToSpeechEndPacket);
263
- await new Promise((resolve) => setTimeout(resolve, 10));
264
- session.bus.push(Route.Main, {
265
- kind: "vad.speech_activity",
266
- contextId: "user",
267
- timestampMs: t0 + 300,
268
- isAsync: true,
269
- } satisfies VadSpeechActivityPacket);
270
- await new Promise((resolve) => setTimeout(resolve, 20));
271
-
272
- expect(interrupts).toEqual([]);
273
- expect(metrics).toContain("interrupt.gate_resolved_after_tts_end");
274
-
275
- await closeSession(session);
276
- });
277
-
278
- it("defers immediate cut until vad.audio when speaker profile is enrolled", async () => {
279
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 0 });
280
- const interrupts: InterruptTtsPacket[] = [];
281
- const { names: metrics } = collectMetrics(session);
282
-
283
- await session.start();
284
- session.bus.on("interrupt.tts", (pkt) => {
285
- interrupts.push(pkt as InterruptTtsPacket);
286
- });
287
- await enrollPrimarySpeaker(session);
288
- armAssistantSpeaking(session);
289
- await new Promise((resolve) => setTimeout(resolve, 10));
290
-
291
- const primary = synthesizeTonePcm16({
292
- frequencyHz: PRIMARY_SPEAKER_TONE_HZ,
293
- durationMs: 32,
294
- });
295
- const t0 = 6000;
296
- session.bus.push(Route.Main, {
297
- kind: "vad.speech_started",
298
- contextId: "user-barge",
299
- timestampMs: t0,
300
- confidence: 0.99,
301
- } satisfies VadSpeechStartedPacket);
302
- await new Promise((resolve) => setTimeout(resolve, 10));
303
-
304
- expect(interrupts).toEqual([]);
305
- expect(metrics).not.toContain("vaqi.interruption");
306
-
307
- session.bus.push(Route.Main, {
308
- kind: "vad.audio",
309
- contextId: "user-barge",
310
- timestampMs: t0 + 5,
311
- audio: primary,
312
- } satisfies VadAudioPacket);
313
- await new Promise((resolve) => setTimeout(resolve, 20));
314
-
315
- expect(interrupts).toEqual([
316
- expect.objectContaining({ kind: "interrupt.tts", contextId: "assistant-turn" }),
317
- ]);
318
- expect(metrics).toContain("vaqi.interruption");
319
-
320
- await closeSession(session);
321
- });
322
-
323
- it("locks primary speaker profile on idle speech end", async () => {
324
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
325
- const interrupts: InterruptTtsPacket[] = [];
326
- const { names: metrics } = collectMetrics(session);
327
-
328
- await session.start();
329
- session.bus.on("interrupt.tts", (pkt) => {
330
- interrupts.push(pkt as InterruptTtsPacket);
331
- });
332
-
333
- const enrollChunk = synthesizeTonePcm16({
334
- frequencyHz: PRIMARY_SPEAKER_TONE_HZ,
335
- durationMs: 32,
336
- });
337
- const t0 = 7000;
338
- session.bus.push(Route.Main, {
339
- kind: "vad.speech_started",
340
- contextId: "user-first",
341
- timestampMs: t0,
342
- confidence: 0.99,
343
- } satisfies VadSpeechStartedPacket);
344
- for (let i = 0; i < 8; i += 1) {
345
- session.bus.push(Route.Main, {
346
- kind: "user.audio_received",
347
- contextId: "user-first",
348
- timestampMs: t0 + i * 20,
349
- audio: enrollChunk,
350
- });
351
- }
352
- session.bus.push(Route.Main, {
353
- kind: "vad.speech_ended",
354
- contextId: "user-first",
355
- timestampMs: t0 + 200,
356
- } satisfies VadSpeechEndedPacket);
357
- await new Promise((resolve) => setTimeout(resolve, 10));
358
-
359
- armAssistantSpeaking(session);
360
- await new Promise((resolve) => setTimeout(resolve, 10));
361
-
362
- const bystander = synthesizeTonePcm16({
363
- frequencyHz: BYSTANDER_SPEAKER_TONE_HZ,
364
- durationMs: 32,
365
- });
366
- const t1 = 8000;
367
- session.bus.push(Route.Main, {
368
- kind: "vad.speech_started",
369
- contextId: "user-barge",
370
- timestampMs: t1,
371
- confidence: 0.99,
372
- } satisfies VadSpeechStartedPacket);
373
- for (let i = 0; i < 10; i += 1) {
374
- session.bus.push(Route.Main, {
375
- kind: "vad.audio",
376
- contextId: "user-barge",
377
- timestampMs: t1 + 20 + i * 30,
378
- audio: bystander,
379
- } satisfies VadAudioPacket);
380
- }
381
- session.bus.push(Route.Main, {
382
- kind: "vad.speech_activity",
383
- contextId: "user-barge",
384
- timestampMs: t1 + 320,
385
- isAsync: true,
386
- } satisfies VadSpeechActivityPacket);
387
- await new Promise((resolve) => setTimeout(resolve, 20));
388
-
389
- expect(interrupts).toEqual([]);
390
- expect(metrics).toContain("interrupt.suppressed_non_primary");
391
-
392
- await closeSession(session);
393
- });
394
-
395
- it("ignores uplink audio before vad.speech_started when enrolling the primary speaker", async () => {
396
- const session = new VoiceAgentSession({ plugins: {}, minInterruptionMs: 280 });
397
- const interrupts: InterruptTtsPacket[] = [];
398
- const { names: metrics } = collectMetrics(session);
399
-
400
- await session.start();
401
- session.bus.on("interrupt.tts", (pkt) => {
402
- interrupts.push(pkt as InterruptTtsPacket);
403
- });
404
-
405
- const bystander = synthesizeTonePcm16({
406
- frequencyHz: BYSTANDER_SPEAKER_TONE_HZ,
407
- durationMs: 32,
408
- });
409
- const primary = synthesizeTonePcm16({
410
- frequencyHz: PRIMARY_SPEAKER_TONE_HZ,
411
- durationMs: 32,
412
- });
413
- for (let i = 0; i < 20; i += 1) {
414
- session.bus.push(Route.Main, {
415
- kind: "user.audio_received",
416
- contextId: "user-enroll",
417
- timestampMs: 500 + i * 10,
418
- audio: bystander,
419
- });
420
- }
421
-
422
- const t0 = 1000;
423
- session.bus.push(Route.Main, {
424
- kind: "vad.speech_started",
425
- contextId: "user-enroll",
426
- timestampMs: t0,
427
- confidence: 0.99,
428
- } satisfies VadSpeechStartedPacket);
429
- for (let i = 0; i < 12; i += 1) {
430
- session.bus.push(Route.Main, {
431
- kind: "user.audio_received",
432
- contextId: "user-enroll",
433
- timestampMs: t0 + 20 + i * 20,
434
- audio: primary,
435
- });
436
- }
437
- session.bus.push(Route.Main, {
438
- kind: "vad.speech_ended",
439
- contextId: "user-enroll",
440
- timestampMs: t0 + 300,
441
- } satisfies VadSpeechEndedPacket);
442
- await new Promise((resolve) => setTimeout(resolve, 10));
443
-
444
- armAssistantSpeaking(session);
445
- await new Promise((resolve) => setTimeout(resolve, 10));
446
-
447
- const t1 = 2000;
448
- session.bus.push(Route.Main, {
449
- kind: "vad.speech_started",
450
- contextId: "user-barge",
451
- timestampMs: t1,
452
- confidence: 0.99,
453
- } satisfies VadSpeechStartedPacket);
454
- for (let i = 0; i < 8; i += 1) {
455
- session.bus.push(Route.Main, {
456
- kind: "vad.audio",
457
- contextId: "user-barge",
458
- timestampMs: t1 + 20 + i * 30,
459
- audio: primary,
460
- } satisfies VadAudioPacket);
461
- }
462
- session.bus.push(Route.Main, {
463
- kind: "vad.speech_activity",
464
- contextId: "user-barge",
465
- timestampMs: t1 + 320,
466
- isAsync: true,
467
- } satisfies VadSpeechActivityPacket);
468
- await new Promise((resolve) => setTimeout(resolve, 20));
469
-
470
- expect(interrupts).toEqual([
471
- expect.objectContaining({ kind: "interrupt.tts", contextId: "assistant-turn" }),
472
- ]);
473
- expect(metrics).not.toContain("interrupt.suppressed_non_primary");
474
-
475
- await closeSession(session);
476
- });
477
- });