@kuralle-syrinx/vap 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 +3 -3
- package/src/index.ts +25 -1
- package/src/vap-policy.test.ts +0 -305
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/vap",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Voice Activity Projection interaction policy for Syrinx — ONNX turn-taking for Node and Workers",
|
|
6
6
|
"keywords": [
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"onnxruntime-node": "1.24.3",
|
|
34
34
|
"onnxruntime-web": "1.24.3",
|
|
35
|
-
"@kuralle-syrinx/core": "4.
|
|
35
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"typescript": "^5.7.0",
|
|
39
|
-
"vitest": "^2.
|
|
39
|
+
"vitest": "^3.2.6"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
3
|
import type { PluginConfig } from "@kuralle-syrinx/core";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AcousticSignalSink,
|
|
6
|
+
InteractionDecision,
|
|
7
|
+
InteractionObservation,
|
|
8
|
+
InteractionPolicy,
|
|
9
|
+
} from "@kuralle-syrinx/core";
|
|
5
10
|
import {
|
|
6
11
|
DEFAULT_VAP_THRESHOLDS,
|
|
7
12
|
RollingFeatureBuffer,
|
|
@@ -76,6 +81,7 @@ export class VapInteractionPolicy implements InteractionPolicy {
|
|
|
76
81
|
private ttsContextId = "";
|
|
77
82
|
private delegateInFlight = false;
|
|
78
83
|
private initialized = false;
|
|
84
|
+
private acousticSignalSink: AcousticSignalSink | undefined;
|
|
79
85
|
|
|
80
86
|
constructor(deps: VapInteractionPolicyDeps) {
|
|
81
87
|
this.predictor = deps.predictor;
|
|
@@ -88,6 +94,10 @@ export class VapInteractionPolicy implements InteractionPolicy {
|
|
|
88
94
|
this.initialized = true;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
setAcousticSignalSink(sink: AcousticSignalSink): void {
|
|
98
|
+
this.acousticSignalSink = sink;
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
observe(obs: InteractionObservation): readonly InteractionDecision[] {
|
|
92
102
|
switch (obs.kind) {
|
|
93
103
|
case "playout_tick":
|
|
@@ -136,6 +146,7 @@ export class VapInteractionPolicy implements InteractionPolicy {
|
|
|
136
146
|
this.inferenceChains.clear();
|
|
137
147
|
this.contextEpochs.clear();
|
|
138
148
|
this.initialized = false;
|
|
149
|
+
this.acousticSignalSink = undefined;
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
private observeAudioFrame(
|
|
@@ -178,6 +189,19 @@ export class VapInteractionPolicy implements InteractionPolicy {
|
|
|
178
189
|
const probs = await this.predictor.push(stableFrame);
|
|
179
190
|
if ((this.contextEpochs.get(frame.contextId) ?? 0) === epoch) {
|
|
180
191
|
this.cachedProbs.set(frame.contextId, probs);
|
|
192
|
+
if (this.initialized) {
|
|
193
|
+
this.acousticSignalSink?.({
|
|
194
|
+
contextId: frame.contextId,
|
|
195
|
+
timestampMs: frame.timestampMs,
|
|
196
|
+
signal: "prosody",
|
|
197
|
+
payload: {
|
|
198
|
+
channel: frame.channel,
|
|
199
|
+
pShift: probs.pShift,
|
|
200
|
+
pBackchannel: probs.pBackchannel,
|
|
201
|
+
pHold: probs.pHold,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
181
205
|
}
|
|
182
206
|
});
|
|
183
207
|
const settled = next.catch(() => undefined).finally(() => {
|
package/src/vap-policy.test.ts
DELETED
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
DEFAULT_VAP_THRESHOLDS,
|
|
7
|
-
RollingFeatureBuffer,
|
|
8
|
-
decideFromProbs,
|
|
9
|
-
semanticTranscriptFusion,
|
|
10
|
-
type VapPredictor,
|
|
11
|
-
type VapPredictorFrame,
|
|
12
|
-
type VapProbs,
|
|
13
|
-
} from "./vap-policy.js";
|
|
14
|
-
import { StubVapPredictor, VapInteractionPolicy } from "./index.js";
|
|
15
|
-
|
|
16
|
-
function audioFrame(contextId = "ctx-1", audio = new Int16Array(320)) {
|
|
17
|
-
return {
|
|
18
|
-
kind: "audio_frame" as const,
|
|
19
|
-
contextId,
|
|
20
|
-
timestampMs: Date.now(),
|
|
21
|
-
audio,
|
|
22
|
-
sampleRateHz: 16_000,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function flushInference(): Promise<void> {
|
|
27
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("decideFromProbs", () => {
|
|
31
|
-
it("maps each threshold path to the expected InteractionDecision", () => {
|
|
32
|
-
const thresholds = DEFAULT_VAP_THRESHOLDS;
|
|
33
|
-
expect(
|
|
34
|
-
decideFromProbs(
|
|
35
|
-
{ pShift: thresholds.shift + 0.1, pBackchannel: 0, pHold: 0 },
|
|
36
|
-
{ ttsActive: true, ttsContextId: "tts-1", delegateInFlight: false },
|
|
37
|
-
thresholds,
|
|
38
|
-
),
|
|
39
|
-
).toEqual([{ kind: "interrupt", interruptedContextId: "tts-1" }]);
|
|
40
|
-
|
|
41
|
-
expect(
|
|
42
|
-
decideFromProbs(
|
|
43
|
-
{ pShift: 0, pBackchannel: thresholds.backchannel + 0.1, pHold: 0 },
|
|
44
|
-
{ ttsActive: false, ttsContextId: "", delegateInFlight: true },
|
|
45
|
-
thresholds,
|
|
46
|
-
),
|
|
47
|
-
).toEqual([{ kind: "backchannel", cue: "mhmm" }]);
|
|
48
|
-
|
|
49
|
-
expect(
|
|
50
|
-
decideFromProbs(
|
|
51
|
-
{ pShift: thresholds.take + 0.1, pBackchannel: 0, pHold: 0 },
|
|
52
|
-
{ ttsActive: false, ttsContextId: "", delegateInFlight: false },
|
|
53
|
-
thresholds,
|
|
54
|
-
),
|
|
55
|
-
).toEqual([{ kind: "take_turn", confidence: thresholds.take + 0.1 }]);
|
|
56
|
-
|
|
57
|
-
expect(
|
|
58
|
-
decideFromProbs(
|
|
59
|
-
{ pShift: 0, pBackchannel: 0, pHold: thresholds.hold + 0.1 },
|
|
60
|
-
{ ttsActive: false, ttsContextId: "", delegateInFlight: false },
|
|
61
|
-
thresholds,
|
|
62
|
-
),
|
|
63
|
-
).toEqual([{ kind: "hold" }]);
|
|
64
|
-
|
|
65
|
-
expect(
|
|
66
|
-
decideFromProbs(
|
|
67
|
-
{ pShift: 0.1, pBackchannel: 0.1, pHold: 0.1 },
|
|
68
|
-
{ ttsActive: false, ttsContextId: "", delegateInFlight: false },
|
|
69
|
-
thresholds,
|
|
70
|
-
),
|
|
71
|
-
).toEqual([{ kind: "keep_listening" }]);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("VapInteractionPolicy", () => {
|
|
76
|
-
it("returns cached decisions synchronously from observe (test:vap-thresholds)", async () => {
|
|
77
|
-
const predictor = new StubVapPredictor();
|
|
78
|
-
predictor.script([
|
|
79
|
-
{ pShift: DEFAULT_VAP_THRESHOLDS.take + 0.2, pBackchannel: 0, pHold: 0 },
|
|
80
|
-
]);
|
|
81
|
-
const policy = new VapInteractionPolicy({ predictor });
|
|
82
|
-
await policy.initialize();
|
|
83
|
-
|
|
84
|
-
const first = policy.observe(audioFrame());
|
|
85
|
-
expect(first).toEqual([{ kind: "keep_listening" }]);
|
|
86
|
-
|
|
87
|
-
await flushInference();
|
|
88
|
-
const second = policy.observe(audioFrame());
|
|
89
|
-
expect(second).toEqual([
|
|
90
|
-
{ kind: "take_turn", confidence: DEFAULT_VAP_THRESHOLDS.take + 0.2 },
|
|
91
|
-
]);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("gates interrupt on ttsActive and backchannel on delegateInFlight", async () => {
|
|
95
|
-
const predictor = new StubVapPredictor();
|
|
96
|
-
predictor.setFallback({
|
|
97
|
-
pShift: DEFAULT_VAP_THRESHOLDS.shift + 0.1,
|
|
98
|
-
pBackchannel: 0,
|
|
99
|
-
pHold: 0,
|
|
100
|
-
});
|
|
101
|
-
const policy = new VapInteractionPolicy({ predictor });
|
|
102
|
-
await policy.initialize();
|
|
103
|
-
|
|
104
|
-
policy.observe(audioFrame());
|
|
105
|
-
await flushInference();
|
|
106
|
-
expect(policy.observe(audioFrame())).toEqual([
|
|
107
|
-
{ kind: "take_turn", confidence: DEFAULT_VAP_THRESHOLDS.shift + 0.1 },
|
|
108
|
-
]);
|
|
109
|
-
|
|
110
|
-
policy.observe({
|
|
111
|
-
kind: "playout_tick",
|
|
112
|
-
contextId: "tts-ctx",
|
|
113
|
-
timestampMs: Date.now(),
|
|
114
|
-
ttsActive: true,
|
|
115
|
-
});
|
|
116
|
-
expect(policy.observe(audioFrame())).toEqual([
|
|
117
|
-
{ kind: "interrupt", interruptedContextId: "tts-ctx" },
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
policy.observe({
|
|
121
|
-
kind: "playout_tick",
|
|
122
|
-
contextId: "tts-ctx",
|
|
123
|
-
timestampMs: Date.now(),
|
|
124
|
-
ttsActive: false,
|
|
125
|
-
});
|
|
126
|
-
predictor.setFallback({
|
|
127
|
-
pShift: 0,
|
|
128
|
-
pBackchannel: DEFAULT_VAP_THRESHOLDS.backchannel + 0.1,
|
|
129
|
-
pHold: 0,
|
|
130
|
-
});
|
|
131
|
-
policy.observe(audioFrame());
|
|
132
|
-
await flushInference();
|
|
133
|
-
|
|
134
|
-
policy.observe({
|
|
135
|
-
kind: "delegate_state",
|
|
136
|
-
contextId: "ctx-1",
|
|
137
|
-
timestampMs: Date.now(),
|
|
138
|
-
delegateInFlight: false,
|
|
139
|
-
});
|
|
140
|
-
expect(policy.observe(audioFrame())).toEqual([{ kind: "keep_listening" }]);
|
|
141
|
-
|
|
142
|
-
policy.observe({
|
|
143
|
-
kind: "delegate_state",
|
|
144
|
-
contextId: "ctx-1",
|
|
145
|
-
timestampMs: Date.now(),
|
|
146
|
-
delegateInFlight: true,
|
|
147
|
-
});
|
|
148
|
-
expect(policy.observe(audioFrame())).toEqual([{ kind: "backchannel", cue: "mhmm" }]);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("serializes stable user and assistant frames and resets predictor state", async () => {
|
|
152
|
-
const pushed: VapPredictorFrame[] = [];
|
|
153
|
-
const resets: string[] = [];
|
|
154
|
-
const predictor: VapPredictor = {
|
|
155
|
-
async initialize() {},
|
|
156
|
-
async push(frame) {
|
|
157
|
-
pushed.push(frame);
|
|
158
|
-
return { pShift: 0, pBackchannel: 0, pHold: 0 };
|
|
159
|
-
},
|
|
160
|
-
reset(contextId) {
|
|
161
|
-
resets.push(contextId);
|
|
162
|
-
},
|
|
163
|
-
async close() {},
|
|
164
|
-
};
|
|
165
|
-
const policy = new VapInteractionPolicy({ predictor });
|
|
166
|
-
await policy.initialize();
|
|
167
|
-
const userAudio = new Int16Array([1, 2]);
|
|
168
|
-
const assistantAudio = new Int16Array([3, 4]);
|
|
169
|
-
|
|
170
|
-
policy.observe(audioFrame("duplex", userAudio));
|
|
171
|
-
policy.observe({
|
|
172
|
-
kind: "playout_tick",
|
|
173
|
-
contextId: "duplex",
|
|
174
|
-
timestampMs: 2,
|
|
175
|
-
ttsActive: true,
|
|
176
|
-
audio: assistantAudio,
|
|
177
|
-
sampleRateHz: 24_000,
|
|
178
|
-
});
|
|
179
|
-
userAudio[0] = 99;
|
|
180
|
-
assistantAudio[0] = 99;
|
|
181
|
-
await flushInference();
|
|
182
|
-
|
|
183
|
-
expect(pushed.map(({ channel, audio, sampleRateHz }) => ({
|
|
184
|
-
channel,
|
|
185
|
-
audio: [...audio],
|
|
186
|
-
sampleRateHz,
|
|
187
|
-
}))).toEqual([
|
|
188
|
-
{ channel: "user", audio: [1, 2], sampleRateHz: 16_000 },
|
|
189
|
-
{ channel: "assistant", audio: [3, 4], sampleRateHz: 24_000 },
|
|
190
|
-
]);
|
|
191
|
-
|
|
192
|
-
policy.reset("duplex");
|
|
193
|
-
expect(resets).toEqual(["duplex"]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("does not run queued frames across reset and drains inference before close", async () => {
|
|
197
|
-
let releaseFirst!: () => void;
|
|
198
|
-
const firstPush = new Promise<void>((resolve) => {
|
|
199
|
-
releaseFirst = resolve;
|
|
200
|
-
});
|
|
201
|
-
const pushed: number[] = [];
|
|
202
|
-
let closed = false;
|
|
203
|
-
const predictor: VapPredictor = {
|
|
204
|
-
async initialize() {},
|
|
205
|
-
async push(frame) {
|
|
206
|
-
pushed.push(frame.audio[0] ?? 0);
|
|
207
|
-
if (pushed.length === 1) await firstPush;
|
|
208
|
-
return { pShift: 0, pBackchannel: 0, pHold: 0 };
|
|
209
|
-
},
|
|
210
|
-
reset() {},
|
|
211
|
-
async close() {
|
|
212
|
-
closed = true;
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
const policy = new VapInteractionPolicy({ predictor });
|
|
216
|
-
await policy.initialize();
|
|
217
|
-
|
|
218
|
-
policy.observe(audioFrame("reset", new Int16Array([1])));
|
|
219
|
-
policy.observe(audioFrame("reset", new Int16Array([2])));
|
|
220
|
-
await flushInference();
|
|
221
|
-
policy.reset("reset");
|
|
222
|
-
const closing = policy.close();
|
|
223
|
-
|
|
224
|
-
expect(closed).toBe(false);
|
|
225
|
-
releaseFirst();
|
|
226
|
-
await closing;
|
|
227
|
-
expect(pushed).toEqual([1]);
|
|
228
|
-
expect(closed).toBe(true);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("can fuse per-context transcript evidence into cached VAP probabilities", async () => {
|
|
232
|
-
const predictor = new StubVapPredictor();
|
|
233
|
-
predictor.setFallback({ pShift: 0.8, pBackchannel: 0, pHold: 0 });
|
|
234
|
-
const policy = new VapInteractionPolicy({ predictor, fuseTranscript: semanticTranscriptFusion });
|
|
235
|
-
await policy.initialize();
|
|
236
|
-
policy.observe(audioFrame("fused"));
|
|
237
|
-
await flushInference();
|
|
238
|
-
|
|
239
|
-
policy.observe({
|
|
240
|
-
kind: "stt_partial",
|
|
241
|
-
contextId: "fused",
|
|
242
|
-
timestampMs: 1,
|
|
243
|
-
text: "I need to",
|
|
244
|
-
});
|
|
245
|
-
expect(policy.observe(audioFrame("fused"))).toEqual([{ kind: "keep_listening" }]);
|
|
246
|
-
|
|
247
|
-
policy.observe({
|
|
248
|
-
kind: "stt_final",
|
|
249
|
-
contextId: "fused",
|
|
250
|
-
timestampMs: 2,
|
|
251
|
-
text: "That is everything.",
|
|
252
|
-
});
|
|
253
|
-
expect(policy.observe(audioFrame("fused"))).toEqual([
|
|
254
|
-
{ kind: "take_turn", confidence: 0.9 },
|
|
255
|
-
]);
|
|
256
|
-
|
|
257
|
-
policy.reset("fused");
|
|
258
|
-
expect(policy.observe(audioFrame("fused"))).toEqual([{ kind: "keep_listening" }]);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("observe audio_frame p99 stays <= 5ms on the sync path (test:vap-latency-bench)", async () => {
|
|
262
|
-
const predictor = new StubVapPredictor();
|
|
263
|
-
predictor.setFallback({
|
|
264
|
-
pShift: DEFAULT_VAP_THRESHOLDS.take + 0.2,
|
|
265
|
-
pBackchannel: 0,
|
|
266
|
-
pHold: 0,
|
|
267
|
-
});
|
|
268
|
-
const policy = new VapInteractionPolicy({ predictor });
|
|
269
|
-
await policy.initialize();
|
|
270
|
-
|
|
271
|
-
const frame = new Int16Array(320);
|
|
272
|
-
const samples = 1000;
|
|
273
|
-
const durations: number[] = [];
|
|
274
|
-
for (let i = 0; i < samples; i += 1) {
|
|
275
|
-
const start = performance.now();
|
|
276
|
-
policy.observe(audioFrame("bench", frame));
|
|
277
|
-
durations.push(performance.now() - start);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
durations.sort((a, b) => a - b);
|
|
281
|
-
const p99 = durations[Math.floor(samples * 0.99) - 1] ?? durations[durations.length - 1] ?? 0;
|
|
282
|
-
expect(p99).toBeLessThanOrEqual(5);
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
describe("RollingFeatureBuffer", () => {
|
|
287
|
-
it("keeps chronological samples after wrap and returns stable snapshots", () => {
|
|
288
|
-
const buffer = new RollingFeatureBuffer(4);
|
|
289
|
-
buffer.append(new Int16Array([3_276, 6_553, 9_830]));
|
|
290
|
-
const first = buffer.snapshot();
|
|
291
|
-
buffer.append(new Int16Array([13_107, 16_384]));
|
|
292
|
-
|
|
293
|
-
expect([...first]).toEqual([
|
|
294
|
-
3_276 / 32_768,
|
|
295
|
-
6_553 / 32_768,
|
|
296
|
-
9_830 / 32_768,
|
|
297
|
-
]);
|
|
298
|
-
expect([...buffer.snapshot()]).toEqual([
|
|
299
|
-
6_553 / 32_768,
|
|
300
|
-
9_830 / 32_768,
|
|
301
|
-
13_107 / 32_768,
|
|
302
|
-
16_384 / 32_768,
|
|
303
|
-
]);
|
|
304
|
-
});
|
|
305
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"noUncheckedIndexedAccess": true,
|
|
9
|
-
"noImplicitReturns": true,
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"declarationMap": true,
|
|
12
|
-
"sourceMap": true,
|
|
13
|
-
"outDir": "./dist",
|
|
14
|
-
"rootDir": "./src",
|
|
15
|
-
"esModuleInterop": true,
|
|
16
|
-
"skipLibCheck": true,
|
|
17
|
-
"forceConsistentCasingInFileNames": true
|
|
18
|
-
},
|
|
19
|
-
"include": ["src/**/*.ts"],
|
|
20
|
-
"exclude": ["node_modules", "dist"]
|
|
21
|
-
}
|