@kuralle-syrinx/core 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 +2 -2
- package/src/audio/alaw.ts +56 -0
- package/src/audio/g722.ts +329 -0
- package/src/audio/index.ts +12 -0
- package/src/audio/loudness.ts +87 -0
- package/src/idle-timeout.ts +1 -0
- package/src/index.ts +63 -3
- package/src/interaction-policy.ts +12 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +110 -2
- package/src/packets.ts +116 -1
- package/src/plugin-contract.ts +41 -0
- package/src/policies/rule-based.ts +17 -2
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +34 -2
- package/src/voice-agent-session.ts +453 -34
- package/src/voice-text.ts +69 -0
- package/src/audio/audio.test.ts +0 -285
- package/src/audio-envelope.test.ts +0 -167
- package/src/confidence-to-wait.test.ts +0 -21
- package/src/error-handler.test.ts +0 -56
- package/src/hedge-throwing-backend.test.ts +0 -46
- package/src/interaction-coordinator.test.ts +0 -425
- package/src/iu-ledger.test.ts +0 -276
- package/src/latency-filler.test.ts +0 -62
- package/src/observability-observer.test.ts +0 -245
- package/src/observability.test.ts +0 -85
- package/src/packet-factories.test.ts +0 -53
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/policies/defer.test.ts +0 -86
- package/src/policies/rule-based.test.ts +0 -269
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner-hedge.test.ts +0 -361
- package/src/reasoner-route.test.ts +0 -248
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- package/src/route-throwing-spec.test.ts +0 -44
- package/src/tts-playout-clock.test.ts +0 -125
- package/src/turn-arbiter.characterization.test.ts +0 -477
- package/src/turn-arbiter.test.ts +0 -567
- package/src/voice-agent-session.test.ts +0 -3316
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
package/src/turn-arbiter.test.ts
DELETED
|
@@ -1,567 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, vi } from "vitest";
|
|
4
|
-
import { PipelineBusImpl, Route } from "./pipeline-bus.js";
|
|
5
|
-
import { TurnArbiter } from "./turn-arbiter.js";
|
|
6
|
-
import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
7
|
-
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
8
|
-
import type {
|
|
9
|
-
InterruptionDetectedPacket,
|
|
10
|
-
VadAudioPacket,
|
|
11
|
-
VadSpeechActivityPacket,
|
|
12
|
-
VadSpeechEndedPacket,
|
|
13
|
-
VadSpeechStartedPacket,
|
|
14
|
-
} from "./packets.js";
|
|
15
|
-
import {
|
|
16
|
-
BYSTANDER_SPEAKER_TONE_HZ,
|
|
17
|
-
PRIMARY_SPEAKER_TONE_HZ,
|
|
18
|
-
synthesizeTonePcm16,
|
|
19
|
-
} from "./primary-speaker-fixtures.js";
|
|
20
|
-
|
|
21
|
-
async function createArbiter(minInterruptionMs: number, gate = new PrimarySpeakerGate()) {
|
|
22
|
-
const bus = new PipelineBusImpl();
|
|
23
|
-
void bus.start();
|
|
24
|
-
const ttsPlayout = new TtsPlayoutClock();
|
|
25
|
-
const arbiter = new TurnArbiter({
|
|
26
|
-
bus,
|
|
27
|
-
primarySpeakerGate: gate,
|
|
28
|
-
ttsPlayout,
|
|
29
|
-
minInterruptionMs,
|
|
30
|
-
});
|
|
31
|
-
return { bus, ttsPlayout, arbiter, gate };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function drainBus(): Promise<void> {
|
|
35
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function metricNames(bus: PipelineBusImpl): string[] {
|
|
39
|
-
const names: string[] = [];
|
|
40
|
-
bus.on("metric.conversation", (pkt) => {
|
|
41
|
-
names.push((pkt as unknown as { name: string }).name);
|
|
42
|
-
});
|
|
43
|
-
return names;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe("TurnArbiter", () => {
|
|
47
|
-
it("commits sustained speech when playout is active", async () => {
|
|
48
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
49
|
-
const metrics = metricNames(bus);
|
|
50
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
51
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
52
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
56
|
-
arbiter.onSpeechStarted(
|
|
57
|
-
{
|
|
58
|
-
kind: "vad.speech_started",
|
|
59
|
-
contextId: "user",
|
|
60
|
-
timestampMs: 2000,
|
|
61
|
-
confidence: 0.99,
|
|
62
|
-
} satisfies VadSpeechStartedPacket,
|
|
63
|
-
"assistant-turn",
|
|
64
|
-
);
|
|
65
|
-
arbiter.onSpeechActivity({
|
|
66
|
-
kind: "vad.speech_activity",
|
|
67
|
-
contextId: "user",
|
|
68
|
-
timestampMs: 2300,
|
|
69
|
-
isAsync: true,
|
|
70
|
-
} satisfies VadSpeechActivityPacket);
|
|
71
|
-
await drainBus();
|
|
72
|
-
|
|
73
|
-
expect(interrupts).toHaveLength(1);
|
|
74
|
-
expect(interrupts[0]!.contextId).toBe("assistant-turn");
|
|
75
|
-
expect(metrics).toContain("interrupt.committed_after_ms");
|
|
76
|
-
expect(metrics).toContain("vaqi.interruption");
|
|
77
|
-
expect(metrics).toContain("interrupt.latency_ms");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("suppresses short speech on speech_ended", async () => {
|
|
81
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
82
|
-
const metrics = metricNames(bus);
|
|
83
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
84
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
85
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
89
|
-
const t0 = 3000;
|
|
90
|
-
arbiter.onSpeechStarted(
|
|
91
|
-
{
|
|
92
|
-
kind: "vad.speech_started",
|
|
93
|
-
contextId: "user",
|
|
94
|
-
timestampMs: t0,
|
|
95
|
-
confidence: 0.99,
|
|
96
|
-
} satisfies VadSpeechStartedPacket,
|
|
97
|
-
"assistant-turn",
|
|
98
|
-
);
|
|
99
|
-
arbiter.onSpeechEnded(
|
|
100
|
-
{
|
|
101
|
-
kind: "vad.speech_ended",
|
|
102
|
-
contextId: "user",
|
|
103
|
-
timestampMs: t0 + 120,
|
|
104
|
-
} satisfies VadSpeechEndedPacket,
|
|
105
|
-
true,
|
|
106
|
-
);
|
|
107
|
-
await drainBus();
|
|
108
|
-
|
|
109
|
-
expect(interrupts).toEqual([]);
|
|
110
|
-
expect(metrics).toContain("interrupt.suppressed_short_speech");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("emits gate_resolved_after_tts_end when playout finished", async () => {
|
|
114
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
115
|
-
const metrics = metricNames(bus);
|
|
116
|
-
|
|
117
|
-
ttsPlayout.noteAudio("assistant-turn", 50, 1000);
|
|
118
|
-
ttsPlayout.scheduleRelease("assistant-turn", 1050);
|
|
119
|
-
const t0 = 4000;
|
|
120
|
-
arbiter.onSpeechStarted(
|
|
121
|
-
{
|
|
122
|
-
kind: "vad.speech_started",
|
|
123
|
-
contextId: "user",
|
|
124
|
-
timestampMs: t0,
|
|
125
|
-
confidence: 0.99,
|
|
126
|
-
} satisfies VadSpeechStartedPacket,
|
|
127
|
-
"assistant-turn",
|
|
128
|
-
);
|
|
129
|
-
arbiter.onSpeechActivity({
|
|
130
|
-
kind: "vad.speech_activity",
|
|
131
|
-
contextId: "user",
|
|
132
|
-
timestampMs: t0 + 300,
|
|
133
|
-
isAsync: true,
|
|
134
|
-
} satisfies VadSpeechActivityPacket);
|
|
135
|
-
await drainBus();
|
|
136
|
-
|
|
137
|
-
expect(metrics).toContain("interrupt.gate_resolved_after_tts_end");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("defers minInterruptionMs 0 cut until barge-in audio when profile exists", async () => {
|
|
141
|
-
const gate = new PrimarySpeakerGate();
|
|
142
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(0, gate);
|
|
143
|
-
const metrics = metricNames(bus);
|
|
144
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
145
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
146
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
const enroll = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
150
|
-
for (let i = 0; i < 12; i += 1) {
|
|
151
|
-
gate.enrollUserTurnChunk(enroll);
|
|
152
|
-
}
|
|
153
|
-
gate.lockProfileFromFirstTurn();
|
|
154
|
-
|
|
155
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 5000);
|
|
156
|
-
arbiter.onSpeechStarted(
|
|
157
|
-
{
|
|
158
|
-
kind: "vad.speech_started",
|
|
159
|
-
contextId: "user",
|
|
160
|
-
timestampMs: 6000,
|
|
161
|
-
confidence: 0.99,
|
|
162
|
-
} satisfies VadSpeechStartedPacket,
|
|
163
|
-
"assistant-turn",
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
expect(interrupts).toEqual([]);
|
|
167
|
-
expect(metrics).not.toContain("vaqi.interruption");
|
|
168
|
-
|
|
169
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
170
|
-
arbiter.observeBargeInAudio({
|
|
171
|
-
kind: "vad.audio",
|
|
172
|
-
contextId: "user",
|
|
173
|
-
timestampMs: 6005,
|
|
174
|
-
audio: primary,
|
|
175
|
-
} satisfies VadAudioPacket);
|
|
176
|
-
await drainBus();
|
|
177
|
-
|
|
178
|
-
expect(interrupts).toHaveLength(1);
|
|
179
|
-
expect(metrics).toContain("vaqi.interruption");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("suppresses non-primary sustained barge-in", async () => {
|
|
183
|
-
const gate = new PrimarySpeakerGate();
|
|
184
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280, gate);
|
|
185
|
-
const metrics = metricNames(bus);
|
|
186
|
-
|
|
187
|
-
const enroll = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
188
|
-
for (let i = 0; i < 12; i += 1) {
|
|
189
|
-
gate.enrollUserTurnChunk(enroll);
|
|
190
|
-
}
|
|
191
|
-
gate.lockProfileFromFirstTurn();
|
|
192
|
-
|
|
193
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 7000);
|
|
194
|
-
const bystander = synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
195
|
-
const t0 = 8000;
|
|
196
|
-
arbiter.onSpeechStarted(
|
|
197
|
-
{
|
|
198
|
-
kind: "vad.speech_started",
|
|
199
|
-
contextId: "user",
|
|
200
|
-
timestampMs: t0,
|
|
201
|
-
confidence: 0.99,
|
|
202
|
-
} satisfies VadSpeechStartedPacket,
|
|
203
|
-
"assistant-turn",
|
|
204
|
-
);
|
|
205
|
-
for (let i = 0; i < 8; i += 1) {
|
|
206
|
-
arbiter.observeBargeInAudio({
|
|
207
|
-
kind: "vad.audio",
|
|
208
|
-
contextId: "user",
|
|
209
|
-
timestampMs: t0 + 20 + i * 30,
|
|
210
|
-
audio: bystander,
|
|
211
|
-
} satisfies VadAudioPacket);
|
|
212
|
-
}
|
|
213
|
-
arbiter.onSpeechActivity({
|
|
214
|
-
kind: "vad.speech_activity",
|
|
215
|
-
contextId: "user",
|
|
216
|
-
timestampMs: t0 + 320,
|
|
217
|
-
isAsync: true,
|
|
218
|
-
} satisfies VadSpeechActivityPacket);
|
|
219
|
-
await drainBus();
|
|
220
|
-
|
|
221
|
-
expect(metrics).toContain("interrupt.suppressed_non_primary");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("recommits after non-primary suppression when primary speech continues", async () => {
|
|
225
|
-
const gate = new PrimarySpeakerGate();
|
|
226
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280, gate);
|
|
227
|
-
const metrics = metricNames(bus);
|
|
228
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
229
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
230
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const enroll = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
234
|
-
for (let i = 0; i < 12; i += 1) {
|
|
235
|
-
gate.enrollUserTurnChunk(enroll);
|
|
236
|
-
}
|
|
237
|
-
gate.lockProfileFromFirstTurn();
|
|
238
|
-
|
|
239
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 7000);
|
|
240
|
-
const bystander = synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
241
|
-
const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
242
|
-
const t0 = 9000;
|
|
243
|
-
arbiter.onSpeechStarted(
|
|
244
|
-
{
|
|
245
|
-
kind: "vad.speech_started",
|
|
246
|
-
contextId: "user",
|
|
247
|
-
timestampMs: t0,
|
|
248
|
-
confidence: 0.99,
|
|
249
|
-
} satisfies VadSpeechStartedPacket,
|
|
250
|
-
"assistant-turn",
|
|
251
|
-
);
|
|
252
|
-
for (let i = 0; i < 8; i += 1) {
|
|
253
|
-
arbiter.observeBargeInAudio({
|
|
254
|
-
kind: "vad.audio",
|
|
255
|
-
contextId: "user",
|
|
256
|
-
timestampMs: t0 + 20 + i * 30,
|
|
257
|
-
audio: bystander,
|
|
258
|
-
} satisfies VadAudioPacket);
|
|
259
|
-
}
|
|
260
|
-
arbiter.onSpeechActivity({
|
|
261
|
-
kind: "vad.speech_activity",
|
|
262
|
-
contextId: "user",
|
|
263
|
-
timestampMs: t0 + 320,
|
|
264
|
-
isAsync: true,
|
|
265
|
-
} satisfies VadSpeechActivityPacket);
|
|
266
|
-
await drainBus();
|
|
267
|
-
expect(metrics).toContain("interrupt.suppressed_non_primary");
|
|
268
|
-
expect(interrupts).toEqual([]);
|
|
269
|
-
|
|
270
|
-
for (let i = 0; i < 6; i += 1) {
|
|
271
|
-
arbiter.observeBargeInAudio({
|
|
272
|
-
kind: "vad.audio",
|
|
273
|
-
contextId: "user",
|
|
274
|
-
timestampMs: t0 + 400 + i * 30,
|
|
275
|
-
audio: primary,
|
|
276
|
-
} satisfies VadAudioPacket);
|
|
277
|
-
}
|
|
278
|
-
arbiter.onSpeechActivity({
|
|
279
|
-
kind: "vad.speech_activity",
|
|
280
|
-
contextId: "user",
|
|
281
|
-
timestampMs: t0 + 620,
|
|
282
|
-
isAsync: true,
|
|
283
|
-
} satisfies VadSpeechActivityPacket);
|
|
284
|
-
await drainBus();
|
|
285
|
-
|
|
286
|
-
expect(interrupts).toHaveLength(1);
|
|
287
|
-
expect(metrics).toContain("interrupt.committed_after_ms");
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("suppresses backchannel interim at commit (test:backchannel_suppressed)", async () => {
|
|
291
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
292
|
-
const metrics = metricNames(bus);
|
|
293
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
294
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
295
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
299
|
-
// Realistic ordering: VAD detects onset first, then STT emits the interim for
|
|
300
|
-
// this same utterance, then sustained activity drives the commit decision.
|
|
301
|
-
arbiter.onSpeechStarted(
|
|
302
|
-
{
|
|
303
|
-
kind: "vad.speech_started",
|
|
304
|
-
contextId: "user",
|
|
305
|
-
timestampMs: 2000,
|
|
306
|
-
confidence: 0.99,
|
|
307
|
-
} satisfies VadSpeechStartedPacket,
|
|
308
|
-
"assistant-turn",
|
|
309
|
-
);
|
|
310
|
-
arbiter.noteInterimEvidence("uh huh");
|
|
311
|
-
arbiter.onSpeechActivity({
|
|
312
|
-
kind: "vad.speech_activity",
|
|
313
|
-
contextId: "user",
|
|
314
|
-
timestampMs: 2300,
|
|
315
|
-
isAsync: true,
|
|
316
|
-
} satisfies VadSpeechActivityPacket);
|
|
317
|
-
await drainBus();
|
|
318
|
-
|
|
319
|
-
expect(interrupts).toEqual([]);
|
|
320
|
-
expect(metrics).toContain("interrupt.suppressed_backchannel");
|
|
321
|
-
expect(metrics).not.toContain("interrupt.committed_after_ms");
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("discards stale interim evidence from before the barge-in window (no false suppression)", async () => {
|
|
325
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
326
|
-
const metrics = metricNames(bus);
|
|
327
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
328
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
329
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
// A backchannel interim lands while no barge-in is pending (e.g. STT emits it
|
|
333
|
-
// during a lull). It must NOT survive into the next, real barge-in's decision.
|
|
334
|
-
arbiter.noteInterimEvidence("uh huh");
|
|
335
|
-
|
|
336
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
337
|
-
arbiter.onSpeechStarted(
|
|
338
|
-
{
|
|
339
|
-
kind: "vad.speech_started",
|
|
340
|
-
contextId: "user",
|
|
341
|
-
timestampMs: 2000,
|
|
342
|
-
confidence: 0.99,
|
|
343
|
-
} satisfies VadSpeechStartedPacket,
|
|
344
|
-
"assistant-turn",
|
|
345
|
-
);
|
|
346
|
-
// Sustained barge-in with no fresh interim for this turn: the stale "uh huh"
|
|
347
|
-
// from before the window must have been cleared, so this commits.
|
|
348
|
-
arbiter.onSpeechActivity({
|
|
349
|
-
kind: "vad.speech_activity",
|
|
350
|
-
contextId: "user",
|
|
351
|
-
timestampMs: 2300,
|
|
352
|
-
isAsync: true,
|
|
353
|
-
} satisfies VadSpeechActivityPacket);
|
|
354
|
-
await drainBus();
|
|
355
|
-
|
|
356
|
-
expect(interrupts).toHaveLength(1);
|
|
357
|
-
expect(metrics).toContain("interrupt.committed_after_ms");
|
|
358
|
-
expect(metrics).not.toContain("interrupt.suppressed_backchannel");
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it("suppresses low-confidence speech evidence with a distinct metric", async () => {
|
|
362
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
363
|
-
const metrics = metricNames(bus);
|
|
364
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
365
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
366
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
370
|
-
// Realistic ordering: onset first, then the low-confidence interim for this
|
|
371
|
-
// utterance, then sustained activity reaching the commit decision.
|
|
372
|
-
arbiter.onSpeechStarted(
|
|
373
|
-
{
|
|
374
|
-
kind: "vad.speech_started",
|
|
375
|
-
contextId: "user",
|
|
376
|
-
timestampMs: 2000,
|
|
377
|
-
confidence: 0.99,
|
|
378
|
-
} satisfies VadSpeechStartedPacket,
|
|
379
|
-
"assistant-turn",
|
|
380
|
-
);
|
|
381
|
-
arbiter.noteInterimEvidence("I need help", 0.21);
|
|
382
|
-
arbiter.onSpeechActivity({
|
|
383
|
-
kind: "vad.speech_activity",
|
|
384
|
-
contextId: "user",
|
|
385
|
-
timestampMs: 2300,
|
|
386
|
-
isAsync: true,
|
|
387
|
-
} satisfies VadSpeechActivityPacket);
|
|
388
|
-
await drainBus();
|
|
389
|
-
|
|
390
|
-
expect(interrupts).toEqual([]);
|
|
391
|
-
expect(metrics).toContain("interrupt.suppressed_low_confidence");
|
|
392
|
-
expect(metrics).not.toContain("interrupt.committed_after_ms");
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it("commits real interruption when interim is not a backchannel (test:real_interrupt_not_suppressed)", async () => {
|
|
396
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
397
|
-
const metrics = metricNames(bus);
|
|
398
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
399
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
400
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
404
|
-
arbiter.noteInterimEvidence("wait stop");
|
|
405
|
-
arbiter.onSpeechStarted(
|
|
406
|
-
{
|
|
407
|
-
kind: "vad.speech_started",
|
|
408
|
-
contextId: "user",
|
|
409
|
-
timestampMs: 2000,
|
|
410
|
-
confidence: 0.99,
|
|
411
|
-
} satisfies VadSpeechStartedPacket,
|
|
412
|
-
"assistant-turn",
|
|
413
|
-
);
|
|
414
|
-
arbiter.onSpeechActivity({
|
|
415
|
-
kind: "vad.speech_activity",
|
|
416
|
-
contextId: "user",
|
|
417
|
-
timestampMs: 2300,
|
|
418
|
-
isAsync: true,
|
|
419
|
-
} satisfies VadSpeechActivityPacket);
|
|
420
|
-
await drainBus();
|
|
421
|
-
|
|
422
|
-
expect(interrupts).toHaveLength(1);
|
|
423
|
-
expect(interrupts[0]!.contextId).toBe("assistant-turn");
|
|
424
|
-
expect(metrics).toContain("interrupt.committed_after_ms");
|
|
425
|
-
expect(metrics).not.toContain("interrupt.suppressed_backchannel");
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it("commits sustained speech without interim evidence unchanged", async () => {
|
|
429
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
430
|
-
const metrics = metricNames(bus);
|
|
431
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
432
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
433
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
437
|
-
arbiter.onSpeechStarted(
|
|
438
|
-
{
|
|
439
|
-
kind: "vad.speech_started",
|
|
440
|
-
contextId: "user",
|
|
441
|
-
timestampMs: 2000,
|
|
442
|
-
confidence: 0.99,
|
|
443
|
-
} satisfies VadSpeechStartedPacket,
|
|
444
|
-
"assistant-turn",
|
|
445
|
-
);
|
|
446
|
-
arbiter.onSpeechActivity({
|
|
447
|
-
kind: "vad.speech_activity",
|
|
448
|
-
contextId: "user",
|
|
449
|
-
timestampMs: 2300,
|
|
450
|
-
isAsync: true,
|
|
451
|
-
} satisfies VadSpeechActivityPacket);
|
|
452
|
-
await drainBus();
|
|
453
|
-
|
|
454
|
-
expect(interrupts).toHaveLength(1);
|
|
455
|
-
expect(metrics).not.toContain("interrupt.suppressed_backchannel");
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it("locks profile on idle speech end", async () => {
|
|
459
|
-
const gate = new PrimarySpeakerGate();
|
|
460
|
-
const { arbiter } = await createArbiter(280, gate);
|
|
461
|
-
const lockSpy = vi.spyOn(gate, "lockProfileFromFirstTurn");
|
|
462
|
-
|
|
463
|
-
const enroll = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
|
|
464
|
-
for (let i = 0; i < 8; i += 1) {
|
|
465
|
-
gate.enrollUserTurnChunk(enroll);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
arbiter.onSpeechEnded(
|
|
469
|
-
{
|
|
470
|
-
kind: "vad.speech_ended",
|
|
471
|
-
contextId: "user-first",
|
|
472
|
-
timestampMs: 9000,
|
|
473
|
-
} satisfies VadSpeechEndedPacket,
|
|
474
|
-
false,
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
expect(lockSpy).toHaveBeenCalledOnce();
|
|
478
|
-
});
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
describe("TurnArbiter provider STT evidence", () => {
|
|
482
|
-
it("commits once evidence is sustained past minInterruptionMs", async () => {
|
|
483
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
484
|
-
const metrics = metricNames(bus);
|
|
485
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
486
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
487
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
491
|
-
arbiter.noteInterimEvidence("wait actually");
|
|
492
|
-
arbiter.onProviderSttEvidence("user", 2000, "assistant-turn");
|
|
493
|
-
await drainBus();
|
|
494
|
-
expect(interrupts).toHaveLength(0);
|
|
495
|
-
|
|
496
|
-
arbiter.noteInterimEvidence("wait actually I need");
|
|
497
|
-
arbiter.onProviderSttEvidence("user", 2300, "assistant-turn");
|
|
498
|
-
await drainBus();
|
|
499
|
-
|
|
500
|
-
expect(interrupts).toHaveLength(1);
|
|
501
|
-
expect(interrupts[0]!.contextId).toBe("assistant-turn");
|
|
502
|
-
expect(metrics).toContain("interrupt.committed_after_ms");
|
|
503
|
-
expect(metrics).toContain("vaqi.interruption");
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it("does not commit while evidence is shorter than minInterruptionMs", async () => {
|
|
507
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
508
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
509
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
510
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
514
|
-
arbiter.onProviderSttEvidence("user", 2000, "assistant-turn");
|
|
515
|
-
arbiter.noteInterimEvidence("hm wait");
|
|
516
|
-
arbiter.onProviderSttEvidence("user", 2100, "assistant-turn");
|
|
517
|
-
await drainBus();
|
|
518
|
-
|
|
519
|
-
expect(interrupts).toHaveLength(0);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
it("suppresses backchannel evidence", async () => {
|
|
523
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
524
|
-
const metrics = metricNames(bus);
|
|
525
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
526
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
527
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
531
|
-
arbiter.onProviderSttEvidence("user", 2000, "assistant-turn");
|
|
532
|
-
arbiter.noteInterimEvidence("okay");
|
|
533
|
-
arbiter.onProviderSttEvidence("user", 2300, "assistant-turn");
|
|
534
|
-
await drainBus();
|
|
535
|
-
|
|
536
|
-
expect(interrupts).toHaveLength(0);
|
|
537
|
-
expect(metrics).toContain("interrupt.suppressed_backchannel");
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it("ignores evidence for a different user context while a barge-in is pending", async () => {
|
|
541
|
-
const { bus, ttsPlayout, arbiter } = await createArbiter(280);
|
|
542
|
-
const interrupts: InterruptionDetectedPacket[] = [];
|
|
543
|
-
bus.on("interrupt.detected", (pkt) => {
|
|
544
|
-
interrupts.push(pkt as InterruptionDetectedPacket);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
548
|
-
arbiter.onSpeechStarted(
|
|
549
|
-
{
|
|
550
|
-
kind: "vad.speech_started",
|
|
551
|
-
contextId: "user-a",
|
|
552
|
-
timestampMs: 2000,
|
|
553
|
-
confidence: 0.99,
|
|
554
|
-
} satisfies VadSpeechStartedPacket,
|
|
555
|
-
"assistant-turn",
|
|
556
|
-
);
|
|
557
|
-
arbiter.noteInterimEvidence("something from another stream");
|
|
558
|
-
arbiter.onProviderSttEvidence("user-b", 2400, "assistant-turn");
|
|
559
|
-
await drainBus();
|
|
560
|
-
expect(interrupts).toHaveLength(0);
|
|
561
|
-
|
|
562
|
-
arbiter.onProviderSttEvidence("user-a", 2400, "assistant-turn");
|
|
563
|
-
await drainBus();
|
|
564
|
-
expect(interrupts).toHaveLength(1);
|
|
565
|
-
expect(interrupts[0]!.contextId).toBe("assistant-turn");
|
|
566
|
-
});
|
|
567
|
-
});
|