@kuralle-syrinx/vap 4.2.0 → 4.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/vap",
3
- "version": "4.2.0",
3
+ "version": "4.4.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.2.0"
35
+ "@kuralle-syrinx/core": "4.4.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.7.0",
39
- "vitest": "^2.1.0"
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 { InteractionDecision, InteractionObservation, InteractionPolicy } from "@kuralle-syrinx/core";
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(() => {
@@ -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
- }