@kuralle-syrinx/vap 4.2.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuralle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @kuralle-syrinx/vap
2
+
3
+ Voice Activity Projection (`VapInteractionPolicy`) for Syrinx turn-taking behind the interaction-policy seam.
4
+
5
+ ## Predictors
6
+
7
+ | Class | Runtime | Model source |
8
+ |-------|---------|--------------|
9
+ | `StubVapPredictor` | Any | Deterministic placeholder for tests and integration without a checkpoint |
10
+ | `LocalVapPredictor` | Node (`onnxruntime-node`) | DualTurn bundle at `bundle_path` / `model_path` or `packages/vap/models/dualturn` |
11
+ | `WorkersVapPredictor` | Workers (`onnxruntime-web`) | `model_bytes` or `model_url` config |
12
+
13
+ ## Local DualTurn bundle
14
+
15
+ Build the bundle remotely with:
16
+
17
+ ```bash
18
+ modal run scripts/modal/export_dualturn_bundle.py
19
+ ```
20
+
21
+ The script persists weights in the `syrinx-vap-models` Modal Volume. A local bundle directory contains:
22
+
23
+ - `manifest.json` and `NOTICE.md`
24
+ - Continuous Mimi ONNX + metadata (MAAI Kyoto, CC-BY-4.0; attribution in `NOTICE.md`)
25
+ - DualTurn projection and all turn heads exported from its Apache-2.0 safetensors
26
+ - a patched DualTurn Qwen backbone that consumes `inputs_embeds`, plus its external data file
27
+
28
+ The published DualTurn ONNX is only the Qwen backbone. Its upstream example accepts an `embeds` argument but
29
+ still feeds token IDs, so it is not audio-dependent as written. The Modal export replaces the embedding lookup
30
+ with a real `inputs_embeds` graph input before the bundle is accepted.
31
+
32
+ ## Async/sync split
33
+
34
+ `observe()` stays synchronous: it snapshots PCM and serializes `push(frame)` calls per `contextId`, while reading
35
+ the latest cached probabilities. `LocalVapPredictor` owns per-context Mimi and Qwen causal state. User audio is
36
+ channel 0 and assistant PCM attached to `playout_tick` is channel 1. Decisions may lag inference by one frame.
37
+ Pass `semanticTranscriptFusion` as `fuseTranscript` to arm the C6 VAP+STT policy variant; transcript state is
38
+ scoped and reset with the same `contextId` as predictor state.
39
+
40
+ ## Model sourcing status
41
+
42
+ No model weights are committed. Use `StubVapPredictor` until the attributed bundle is exported and made available
43
+ at the configured path. The Node predictor maps `max(user EOT, user BOT)` to `pShift`, user BC to
44
+ `pBackchannel`, and user HOLD to `pHold`.
45
+
46
+ ## Ring-buffer invariant
47
+
48
+ `RollingFeatureBuffer` writes through a ring cursor and exposes only chronological copies via `snapshot()`.
49
+ The policy also copies every predictor frame before enqueueing it. Async inference therefore never observes a
50
+ typed-array view being mutated by a later audio frame.
File without changes
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kuralle-syrinx/vap",
3
+ "version": "4.2.0",
4
+ "private": false,
5
+ "description": "Voice Activity Projection interaction policy for Syrinx — ONNX turn-taking for Node and Workers",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "vap",
12
+ "turn-taking",
13
+ "onnx"
14
+ ],
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/vap"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./workers": "./src/workers.ts"
31
+ },
32
+ "dependencies": {
33
+ "onnxruntime-node": "1.24.3",
34
+ "onnxruntime-web": "1.24.3",
35
+ "@kuralle-syrinx/core": "4.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.7.0",
39
+ "vitest": "^2.1.0"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ }
45
+ }
@@ -0,0 +1,448 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { StreamingPcm16Resampler, optionalStringConfig, type PluginConfig } from "@kuralle-syrinx/core";
8
+ import {
9
+ ZERO_VAP_PROBS,
10
+ type VapPredictor,
11
+ type VapPredictorFrame,
12
+ type VapProbs,
13
+ } from "./vap-policy.js";
14
+
15
+ type Ort = typeof import("onnxruntime-node");
16
+ type InferenceSession = import("onnxruntime-node").InferenceSession;
17
+ type Tensor = import("onnxruntime-node").Tensor;
18
+
19
+ const DEFAULT_BUNDLE_PATH = fileURLToPath(new URL("../models/dualturn", import.meta.url));
20
+ const MODEL_SAMPLE_RATE_HZ = 24_000;
21
+ const MODEL_WINDOW_SAMPLES = 1_920;
22
+ const QUEUE_CAPACITY_SAMPLES = MODEL_SAMPLE_RATE_HZ * 2;
23
+
24
+ interface DualTurnManifest {
25
+ readonly format: "syrinx.dualturn.onnx.v1";
26
+ readonly context_frames: number;
27
+ readonly files: {
28
+ readonly mimi_model: string;
29
+ readonly mimi_metadata: string;
30
+ readonly projection: string;
31
+ readonly backbone: string;
32
+ readonly heads: string;
33
+ };
34
+ }
35
+
36
+ interface MimiMetadata {
37
+ readonly cache_position_len: number;
38
+ readonly max_past_len: number;
39
+ readonly contract: {
40
+ readonly spec: {
41
+ readonly padding_cache_shapes: readonly (readonly number[])[];
42
+ readonly past_key_value_shapes: readonly (readonly number[])[];
43
+ };
44
+ };
45
+ }
46
+
47
+ interface MimiStreamState {
48
+ cachePosition: number;
49
+ position: number;
50
+ states: Tensor[];
51
+ }
52
+
53
+ interface DualTurnContext {
54
+ readonly queues: Record<VapPredictorFrame["channel"], PcmSampleQueue>;
55
+ readonly resamplers: Map<string, StreamingPcm16Resampler>;
56
+ readonly mimi: Record<VapPredictorFrame["channel"], MimiStreamState>;
57
+ backbonePast: Tensor[];
58
+ backbonePosition: number;
59
+ probs: VapProbs;
60
+ }
61
+
62
+ export class LocalVapPredictor implements VapPredictor {
63
+ private ort: Ort | null = null;
64
+ private mimiSession: InferenceSession | null = null;
65
+ private projectionSession: InferenceSession | null = null;
66
+ private backboneSession: InferenceSession | null = null;
67
+ private headsSession: InferenceSession | null = null;
68
+ private manifest: DualTurnManifest | null = null;
69
+ private mimiMetadata: MimiMetadata | null = null;
70
+ private readonly contexts = new Map<string, DualTurnContext>();
71
+
72
+ async initialize(cfg: PluginConfig = {}): Promise<void> {
73
+ const bundlePath = optionalStringConfig(cfg, "bundle_path")
74
+ ?? optionalStringConfig(cfg, "model_path")
75
+ ?? DEFAULT_BUNDLE_PATH;
76
+ const manifest = parseManifest(await readJson(join(bundlePath, "manifest.json")));
77
+ const mimiMetadata = parseMimiMetadata(
78
+ await readJson(join(bundlePath, manifest.files.mimi_metadata)),
79
+ );
80
+ const ort = await import("onnxruntime-node");
81
+ const options = {
82
+ executionProviders: ["cpu"] as const,
83
+ interOpNumThreads: 1,
84
+ intraOpNumThreads: 1,
85
+ };
86
+ const [mimiSession, projectionSession, backboneSession, headsSession] = await Promise.all([
87
+ ort.InferenceSession.create(join(bundlePath, manifest.files.mimi_model), options),
88
+ ort.InferenceSession.create(join(bundlePath, manifest.files.projection), options),
89
+ ort.InferenceSession.create(join(bundlePath, manifest.files.backbone), options),
90
+ ort.InferenceSession.create(join(bundlePath, manifest.files.heads), options),
91
+ ]);
92
+ this.ort = ort;
93
+ this.manifest = manifest;
94
+ this.mimiMetadata = mimiMetadata;
95
+ this.mimiSession = mimiSession;
96
+ this.projectionSession = projectionSession;
97
+ this.backboneSession = backboneSession;
98
+ this.headsSession = headsSession;
99
+ }
100
+
101
+ async push(frame: VapPredictorFrame): Promise<VapProbs> {
102
+ this.assertInitialized();
103
+ const context = this.contextFor(frame.contextId);
104
+ const resampled = this.resample(context, frame);
105
+ context.queues[frame.channel].append(resampled);
106
+
107
+ while (this.hasReadyWindow(context)) {
108
+ const user = context.queues.user.length >= MODEL_WINDOW_SAMPLES
109
+ ? context.queues.user.drainNormalized(MODEL_WINDOW_SAMPLES)
110
+ : new Float32Array(MODEL_WINDOW_SAMPLES);
111
+ const assistant = context.queues.assistant.length >= MODEL_WINDOW_SAMPLES
112
+ ? context.queues.assistant.drainNormalized(MODEL_WINDOW_SAMPLES)
113
+ : new Float32Array(MODEL_WINDOW_SAMPLES);
114
+ context.probs = await this.inferWindow(context, user, assistant);
115
+ }
116
+ return context.probs;
117
+ }
118
+
119
+ reset(contextId: string): void {
120
+ this.contexts.delete(contextId);
121
+ }
122
+
123
+ async close(): Promise<void> {
124
+ this.contexts.clear();
125
+ this.mimiSession = null;
126
+ this.projectionSession = null;
127
+ this.backboneSession = null;
128
+ this.headsSession = null;
129
+ this.mimiMetadata = null;
130
+ this.manifest = null;
131
+ this.ort = null;
132
+ }
133
+
134
+ private contextFor(contextId: string): DualTurnContext {
135
+ let context = this.contexts.get(contextId);
136
+ if (!context) {
137
+ context = {
138
+ queues: {
139
+ user: new PcmSampleQueue(QUEUE_CAPACITY_SAMPLES),
140
+ assistant: new PcmSampleQueue(QUEUE_CAPACITY_SAMPLES),
141
+ },
142
+ resamplers: new Map(),
143
+ mimi: {
144
+ user: this.newMimiState(),
145
+ assistant: this.newMimiState(),
146
+ },
147
+ backbonePast: [],
148
+ backbonePosition: 0,
149
+ probs: ZERO_VAP_PROBS,
150
+ };
151
+ this.contexts.set(contextId, context);
152
+ }
153
+ return context;
154
+ }
155
+
156
+ private newMimiState(): MimiStreamState {
157
+ const ort = this.requireOrt();
158
+ const metadata = this.requireMimiMetadata();
159
+ const shapes = [
160
+ ...metadata.contract.spec.padding_cache_shapes,
161
+ ...metadata.contract.spec.past_key_value_shapes,
162
+ ];
163
+ return {
164
+ cachePosition: 0,
165
+ position: 0,
166
+ states: shapes.map((shape) => new ort.Tensor(
167
+ "float32",
168
+ new Float32Array(product(shape)),
169
+ [...shape],
170
+ )),
171
+ };
172
+ }
173
+
174
+ private resample(context: DualTurnContext, frame: VapPredictorFrame): Int16Array {
175
+ if (frame.sampleRateHz === MODEL_SAMPLE_RATE_HZ) return frame.audio;
176
+ const key = `${frame.channel}:${String(frame.sampleRateHz)}`;
177
+ let resampler = context.resamplers.get(key);
178
+ if (!resampler) {
179
+ resampler = new StreamingPcm16Resampler(frame.sampleRateHz, MODEL_SAMPLE_RATE_HZ);
180
+ context.resamplers.set(key, resampler);
181
+ }
182
+ return resampler.process(frame.audio);
183
+ }
184
+
185
+ private hasReadyWindow(context: DualTurnContext): boolean {
186
+ const userWindows = Math.floor(context.queues.user.length / MODEL_WINDOW_SAMPLES);
187
+ const assistantWindows = Math.floor(context.queues.assistant.length / MODEL_WINDOW_SAMPLES);
188
+ return (userWindows > 0 && assistantWindows > 0) || userWindows > 1 || assistantWindows > 1;
189
+ }
190
+
191
+ private async inferWindow(
192
+ context: DualTurnContext,
193
+ userAudio: Float32Array,
194
+ assistantAudio: Float32Array,
195
+ ): Promise<VapProbs> {
196
+ const [userFeatures, assistantFeatures] = await Promise.all([
197
+ this.encodeMimi(userAudio, context.mimi.user),
198
+ this.encodeMimi(assistantAudio, context.mimi.assistant),
199
+ ]);
200
+ const projection = await this.requireSession(this.projectionSession, "projection").run({
201
+ user_features: userFeatures,
202
+ assistant_features: assistantFeatures,
203
+ });
204
+ const inputsEmbeds = requireTensor(projection["inputs_embeds"], "inputs_embeds");
205
+ const hiddenStates = await this.runBackbone(context, inputsEmbeds);
206
+ const outputs = await this.requireSession(this.headsSession, "turn heads").run({
207
+ hidden_states: hiddenStates,
208
+ });
209
+ return mapTurnHeads(outputs);
210
+ }
211
+
212
+ private async encodeMimi(audio: Float32Array, state: MimiStreamState): Promise<Tensor> {
213
+ const ort = this.requireOrt();
214
+ const metadata = this.requireMimiMetadata();
215
+ const cpLength = metadata.cache_position_len;
216
+ const cachePosition = new BigInt64Array(cpLength);
217
+ const positionIds = new BigInt64Array(cpLength);
218
+ for (let index = 0; index < cpLength; index += 1) {
219
+ cachePosition[index] = BigInt((state.cachePosition + index) % metadata.max_past_len);
220
+ positionIds[index] = BigInt(state.position + index);
221
+ }
222
+ const feeds: Record<string, Tensor> = {
223
+ wave_24k: new ort.Tensor("float32", audio, [1, 1, MODEL_WINDOW_SAMPLES]),
224
+ cache_position: new ort.Tensor("int64", cachePosition, [cpLength]),
225
+ position_ids: new ort.Tensor("int64", positionIds, [cpLength]),
226
+ };
227
+ const paddingCount = metadata.contract.spec.padding_cache_shapes.length;
228
+ for (let index = 0; index < state.states.length; index += 1) {
229
+ const name = index < paddingCount
230
+ ? `pad_cache_${String(index)}`
231
+ : `past_${String(index - paddingCount)}`;
232
+ feeds[name] = state.states[index]!;
233
+ }
234
+ const output = await this.requireSession(this.mimiSession, "Mimi").run(feeds);
235
+ const cpOut = requireTensor(output["cache_position_out"], "cache_position_out");
236
+ const lastCachePosition = readLastInteger(cpOut);
237
+ state.cachePosition = lastCachePosition ?? ((state.cachePosition + cpLength) % metadata.max_past_len);
238
+ state.position += cpLength;
239
+ state.states = [
240
+ ...Array.from({ length: paddingCount }, (_, index) =>
241
+ requireTensor(output[`pad_cache_out_${String(index)}`], `pad_cache_out_${String(index)}`)),
242
+ ...Array.from({ length: metadata.contract.spec.past_key_value_shapes.length }, (_, index) =>
243
+ requireTensor(output[`past_out_${String(index)}`], `past_out_${String(index)}`)),
244
+ ];
245
+ return normalizeMimiFeatures(requireTensor(output["embeddings"], "embeddings"), ort);
246
+ }
247
+
248
+ private async runBackbone(context: DualTurnContext, inputsEmbeds: Tensor): Promise<Tensor> {
249
+ const ort = this.requireOrt();
250
+ const session = this.requireSession(this.backboneSession, "backbone");
251
+ const timeSteps = inputsEmbeds.dims[1] ?? 0;
252
+ const pastLength = context.backbonePast[0]?.dims[2] ?? 0;
253
+ const feeds: Record<string, Tensor> = {
254
+ inputs_embeds: inputsEmbeds,
255
+ attention_mask: new ort.Tensor(
256
+ "int64",
257
+ filledBigInt(pastLength + timeSteps, 1n),
258
+ [1, pastLength + timeSteps],
259
+ ),
260
+ position_ids: new ort.Tensor(
261
+ "int64",
262
+ rangeBigInt(context.backbonePosition, timeSteps),
263
+ [1, timeSteps],
264
+ ),
265
+ };
266
+ for (let layer = 0; layer < 24; layer += 1) {
267
+ const key = context.backbonePast[layer * 2]
268
+ ?? new ort.Tensor("float32", new Float32Array(0), [1, 2, 0, 64]);
269
+ const value = context.backbonePast[layer * 2 + 1]
270
+ ?? new ort.Tensor("float32", new Float32Array(0), [1, 2, 0, 64]);
271
+ feeds[`past_key_values.${String(layer)}.key`] = key;
272
+ feeds[`past_key_values.${String(layer)}.value`] = value;
273
+ }
274
+ const outputs = await session.run(feeds);
275
+ const hidden = requireTensor(outputs[session.outputNames[0]!], "backbone hidden states");
276
+ context.backbonePast = session.outputNames.slice(1).map((name) =>
277
+ trimQwenCache(requireTensor(outputs[name], name), this.requireManifest().context_frames, ort),
278
+ );
279
+ context.backbonePosition += timeSteps;
280
+ return hidden;
281
+ }
282
+
283
+ private assertInitialized(): void {
284
+ this.requireOrt();
285
+ this.requireManifest();
286
+ this.requireMimiMetadata();
287
+ this.requireSession(this.mimiSession, "Mimi");
288
+ this.requireSession(this.projectionSession, "projection");
289
+ this.requireSession(this.backboneSession, "backbone");
290
+ this.requireSession(this.headsSession, "turn heads");
291
+ }
292
+
293
+ private requireOrt(): Ort {
294
+ if (!this.ort) throw new Error("LocalVapPredictor is not initialized");
295
+ return this.ort;
296
+ }
297
+
298
+ private requireManifest(): DualTurnManifest {
299
+ if (!this.manifest) throw new Error("LocalVapPredictor bundle manifest is not loaded");
300
+ return this.manifest;
301
+ }
302
+
303
+ private requireMimiMetadata(): MimiMetadata {
304
+ if (!this.mimiMetadata) throw new Error("LocalVapPredictor Mimi metadata is not loaded");
305
+ return this.mimiMetadata;
306
+ }
307
+
308
+ private requireSession(session: InferenceSession | null, label: string): InferenceSession {
309
+ if (!session) throw new Error(`LocalVapPredictor ${label} session is not loaded`);
310
+ return session;
311
+ }
312
+ }
313
+
314
+ class PcmSampleQueue {
315
+ private readonly storage: Int16Array;
316
+ private readIndex = 0;
317
+ private writeIndex = 0;
318
+ private sampleCount = 0;
319
+
320
+ constructor(capacity: number) {
321
+ this.storage = new Int16Array(capacity);
322
+ }
323
+
324
+ get length(): number {
325
+ return this.sampleCount;
326
+ }
327
+
328
+ append(audio: Int16Array): void {
329
+ if (audio.length > this.storage.length - this.sampleCount) {
330
+ throw new Error("DualTurn audio queue overflowed while inference was backlogged");
331
+ }
332
+ for (const sample of audio) {
333
+ this.storage[this.writeIndex] = sample;
334
+ this.writeIndex = (this.writeIndex + 1) % this.storage.length;
335
+ this.sampleCount += 1;
336
+ }
337
+ }
338
+
339
+ drainNormalized(count: number): Float32Array {
340
+ if (count > this.sampleCount) throw new Error("DualTurn audio queue underflow");
341
+ const output = new Float32Array(count);
342
+ for (let index = 0; index < count; index += 1) {
343
+ output[index] = this.storage[this.readIndex]! / 32_768;
344
+ this.readIndex = (this.readIndex + 1) % this.storage.length;
345
+ this.sampleCount -= 1;
346
+ }
347
+ return output;
348
+ }
349
+ }
350
+
351
+ async function readJson(path: string): Promise<unknown> {
352
+ return JSON.parse(await readFile(path, "utf8")) as unknown;
353
+ }
354
+
355
+ function parseManifest(value: unknown): DualTurnManifest {
356
+ const manifest = value as Partial<DualTurnManifest>;
357
+ if (manifest.format !== "syrinx.dualturn.onnx.v1" || !manifest.files || !manifest.context_frames) {
358
+ throw new Error("Unsupported or invalid DualTurn bundle manifest");
359
+ }
360
+ return manifest as DualTurnManifest;
361
+ }
362
+
363
+ function parseMimiMetadata(value: unknown): MimiMetadata {
364
+ const metadata = value as Partial<MimiMetadata>;
365
+ if (!metadata.cache_position_len || !metadata.max_past_len || !metadata.contract?.spec) {
366
+ throw new Error("Invalid Continuous Mimi ONNX metadata");
367
+ }
368
+ return metadata as MimiMetadata;
369
+ }
370
+
371
+ function product(shape: readonly number[]): number {
372
+ return shape.reduce((size, dimension) => size * dimension, 1);
373
+ }
374
+
375
+ function requireTensor(tensor: Tensor | undefined, name: string): Tensor {
376
+ if (!tensor) throw new Error(`ONNX output ${name} is missing`);
377
+ return tensor;
378
+ }
379
+
380
+ function readLastInteger(tensor: Tensor): number | undefined {
381
+ const data = tensor.data;
382
+ const value = data[data.length - 1];
383
+ if (typeof value === "bigint") return Number(value);
384
+ if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
385
+ return undefined;
386
+ }
387
+
388
+ function normalizeMimiFeatures(tensor: Tensor, ort: Ort): Tensor {
389
+ const [batch = 0, first = 0, second = 0] = tensor.dims;
390
+ if (batch !== 1) throw new Error(`Mimi emitted unsupported batch size ${String(batch)}`);
391
+ if (second === 512) return tensor;
392
+ if (first !== 512) {
393
+ throw new Error(`Mimi emitted unsupported feature shape [${tensor.dims.join(",")}]`);
394
+ }
395
+ const source = tensor.data as Float32Array;
396
+ const output = new Float32Array(second * first);
397
+ for (let frame = 0; frame < second; frame += 1) {
398
+ for (let feature = 0; feature < first; feature += 1) {
399
+ output[frame * first + feature] = source[feature * second + frame]!;
400
+ }
401
+ }
402
+ return new ort.Tensor("float32", output, [1, second, first]);
403
+ }
404
+
405
+ function rangeBigInt(start: number, length: number): BigInt64Array {
406
+ const output = new BigInt64Array(length);
407
+ for (let index = 0; index < length; index += 1) output[index] = BigInt(start + index);
408
+ return output;
409
+ }
410
+
411
+ function filledBigInt(length: number, value: bigint): BigInt64Array {
412
+ const output = new BigInt64Array(length);
413
+ output.fill(value);
414
+ return output;
415
+ }
416
+
417
+ function trimQwenCache(tensor: Tensor, maxFrames: number, ort: Ort): Tensor {
418
+ const [batch = 0, heads = 0, frames = 0, headSize = 0] = tensor.dims;
419
+ if (frames <= maxFrames) return tensor;
420
+ if (batch !== 1) throw new Error("DualTurn Qwen cache only supports batch size 1");
421
+ const source = tensor.data as Float32Array;
422
+ const output = new Float32Array(heads * maxFrames * headSize);
423
+ for (let head = 0; head < heads; head += 1) {
424
+ const sourceStart = (head * frames + frames - maxFrames) * headSize;
425
+ const destinationStart = head * maxFrames * headSize;
426
+ output.set(source.subarray(sourceStart, sourceStart + maxFrames * headSize), destinationStart);
427
+ }
428
+ return new ort.Tensor("float32", output, [1, heads, maxFrames, headSize]);
429
+ }
430
+
431
+ function mapTurnHeads(output: Record<string, Tensor>): VapProbs {
432
+ const eot = requireFloatOutput(output["eot_probs"], "eot_probs");
433
+ const bot = requireFloatOutput(output["bot_probs"], "bot_probs");
434
+ const hold = requireFloatOutput(output["hold_probs"], "hold_probs");
435
+ const backchannel = requireFloatOutput(output["bc_probs"], "bc_probs");
436
+ const latestUserIndex = eot.length - 2;
437
+ return {
438
+ pShift: Math.max(eot[latestUserIndex] ?? 0, bot[latestUserIndex] ?? 0),
439
+ pBackchannel: backchannel[backchannel.length - 2] ?? 0,
440
+ pHold: hold[hold.length - 2] ?? 0,
441
+ };
442
+ }
443
+
444
+ function requireFloatOutput(tensor: Tensor | undefined, name: string): Float32Array {
445
+ const value = requireTensor(tensor, name).data;
446
+ if (!(value instanceof Float32Array)) throw new Error(`ONNX output ${name} must be float32`);
447
+ return value;
448
+ }
package/src/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PluginConfig } from "@kuralle-syrinx/core";
4
+ import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "@kuralle-syrinx/core";
5
+ import {
6
+ DEFAULT_VAP_THRESHOLDS,
7
+ RollingFeatureBuffer,
8
+ ZERO_VAP_PROBS,
9
+ decideFromProbs,
10
+ type VapDecisionContext,
11
+ type VapPredictor,
12
+ type VapPredictorFrame,
13
+ type VapProbs,
14
+ type VapThresholds,
15
+ type VapTranscriptFusion,
16
+ type VapTranscriptObservation,
17
+ } from "./vap-policy.js";
18
+
19
+ export type {
20
+ VapPredictor,
21
+ VapPredictorFrame,
22
+ VapProbs,
23
+ VapThresholds,
24
+ VapDecisionContext,
25
+ VapTranscriptFusion,
26
+ VapTranscriptObservation,
27
+ } from "./vap-policy.js";
28
+ export {
29
+ DEFAULT_VAP_THRESHOLDS,
30
+ ZERO_VAP_PROBS,
31
+ decideFromProbs,
32
+ RollingFeatureBuffer,
33
+ semanticTranscriptFusion,
34
+ } from "./vap-policy.js";
35
+
36
+ export { LocalVapPredictor } from "./dualturn-predictor.js";
37
+
38
+ export class StubVapPredictor implements VapPredictor {
39
+ private scripted: VapProbs[] = [];
40
+ private fallback: VapProbs = ZERO_VAP_PROBS;
41
+
42
+ script(probs: readonly VapProbs[]): void {
43
+ this.scripted = [...probs];
44
+ }
45
+
46
+ setFallback(probs: VapProbs): void {
47
+ this.fallback = probs;
48
+ }
49
+
50
+ async initialize(_cfg: PluginConfig = {}): Promise<void> {}
51
+
52
+ async push(_frame: VapPredictorFrame): Promise<VapProbs> {
53
+ return this.scripted.shift() ?? this.fallback;
54
+ }
55
+
56
+ reset(_contextId: string): void {}
57
+
58
+ async close(): Promise<void> {}
59
+ }
60
+
61
+ export interface VapInteractionPolicyDeps {
62
+ readonly predictor: VapPredictor;
63
+ readonly thresholds?: VapThresholds;
64
+ readonly fuseTranscript?: VapTranscriptFusion;
65
+ }
66
+
67
+ export class VapInteractionPolicy implements InteractionPolicy {
68
+ private readonly predictor: VapPredictor;
69
+ private readonly thresholds: VapThresholds;
70
+ private readonly fuseTranscript?: VapTranscriptFusion;
71
+ private readonly cachedProbs = new Map<string, VapProbs>();
72
+ private readonly transcripts = new Map<string, VapTranscriptObservation>();
73
+ private readonly inferenceChains = new Map<string, Promise<void>>();
74
+ private readonly contextEpochs = new Map<string, number>();
75
+ private ttsActive = false;
76
+ private ttsContextId = "";
77
+ private delegateInFlight = false;
78
+ private initialized = false;
79
+
80
+ constructor(deps: VapInteractionPolicyDeps) {
81
+ this.predictor = deps.predictor;
82
+ this.thresholds = deps.thresholds ?? DEFAULT_VAP_THRESHOLDS;
83
+ this.fuseTranscript = deps.fuseTranscript;
84
+ }
85
+
86
+ async initialize(cfg: PluginConfig = {}): Promise<void> {
87
+ await this.predictor.initialize(cfg);
88
+ this.initialized = true;
89
+ }
90
+
91
+ observe(obs: InteractionObservation): readonly InteractionDecision[] {
92
+ switch (obs.kind) {
93
+ case "playout_tick":
94
+ this.ttsActive = obs.ttsActive === true;
95
+ if (this.ttsActive) this.ttsContextId = obs.contextId;
96
+ if (obs.audio && obs.audio.length > 0) {
97
+ this.enqueueInference({
98
+ contextId: obs.contextId,
99
+ timestampMs: obs.timestampMs,
100
+ channel: "assistant",
101
+ audio: obs.audio,
102
+ sampleRateHz: obs.sampleRateHz ?? 16_000,
103
+ });
104
+ }
105
+ return [];
106
+ case "delegate_state":
107
+ this.delegateInFlight = obs.delegateInFlight === true;
108
+ return [];
109
+ case "stt_partial":
110
+ case "stt_final":
111
+ this.transcripts.set(obs.contextId, obs);
112
+ return [];
113
+ case "audio_frame":
114
+ return this.observeAudioFrame(obs);
115
+ default:
116
+ return [];
117
+ }
118
+ }
119
+
120
+ reset(contextId: string): void {
121
+ if (this.inferenceChains.has(contextId)) {
122
+ this.contextEpochs.set(contextId, (this.contextEpochs.get(contextId) ?? 0) + 1);
123
+ } else {
124
+ this.contextEpochs.delete(contextId);
125
+ }
126
+ this.cachedProbs.delete(contextId);
127
+ this.transcripts.delete(contextId);
128
+ this.predictor.reset(contextId);
129
+ }
130
+
131
+ async close(): Promise<void> {
132
+ await Promise.allSettled(this.inferenceChains.values());
133
+ await this.predictor.close();
134
+ this.cachedProbs.clear();
135
+ this.transcripts.clear();
136
+ this.inferenceChains.clear();
137
+ this.contextEpochs.clear();
138
+ this.initialized = false;
139
+ }
140
+
141
+ private observeAudioFrame(
142
+ obs: Extract<InteractionObservation, { kind: "audio_frame" }>,
143
+ ): readonly InteractionDecision[] {
144
+ if (!this.initialized) return [{ kind: "keep_listening" }];
145
+ if (obs.audio && obs.audio.length > 0) {
146
+ this.enqueueInference({
147
+ contextId: obs.contextId,
148
+ timestampMs: obs.timestampMs,
149
+ channel: "user",
150
+ audio: obs.audio,
151
+ sampleRateHz: obs.sampleRateHz ?? 16_000,
152
+ });
153
+ }
154
+ const rawProbs = this.cachedProbs.get(obs.contextId) ?? ZERO_VAP_PROBS;
155
+ const transcript = this.transcripts.get(obs.contextId);
156
+ const probs = transcript && this.fuseTranscript
157
+ ? this.fuseTranscript(rawProbs, transcript)
158
+ : rawProbs;
159
+ return decideFromProbs(probs, this.decisionContext());
160
+ }
161
+
162
+ private decisionContext(): VapDecisionContext {
163
+ return {
164
+ ttsActive: this.ttsActive,
165
+ ttsContextId: this.ttsContextId,
166
+ delegateInFlight: this.delegateInFlight,
167
+ };
168
+ }
169
+
170
+ private enqueueInference(frame: VapPredictorFrame): void {
171
+ const stableFrame = { ...frame, audio: frame.audio.slice() };
172
+ const epoch = this.contextEpochs.get(frame.contextId) ?? 0;
173
+ const previous = this.inferenceChains.get(frame.contextId) ?? Promise.resolve();
174
+ const next = previous
175
+ .catch(() => undefined)
176
+ .then(async () => {
177
+ if ((this.contextEpochs.get(frame.contextId) ?? 0) !== epoch) return;
178
+ const probs = await this.predictor.push(stableFrame);
179
+ if ((this.contextEpochs.get(frame.contextId) ?? 0) === epoch) {
180
+ this.cachedProbs.set(frame.contextId, probs);
181
+ }
182
+ });
183
+ const settled = next.catch(() => undefined).finally(() => {
184
+ if (this.inferenceChains.get(frame.contextId) === settled) {
185
+ this.inferenceChains.delete(frame.contextId);
186
+ this.contextEpochs.delete(frame.contextId);
187
+ }
188
+ });
189
+ this.inferenceChains.set(frame.contextId, settled);
190
+ }
191
+ }
@@ -0,0 +1,305 @@
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
+ });
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { InteractionDecision, InteractionObservation, PluginConfig } from "@kuralle-syrinx/core";
4
+
5
+ export interface VapPredictor {
6
+ initialize(cfg: PluginConfig): Promise<void>;
7
+ push(frame: VapPredictorFrame): Promise<VapProbs>;
8
+ reset(contextId: string): void;
9
+ close(): Promise<void>;
10
+ }
11
+
12
+ export interface VapPredictorFrame {
13
+ readonly contextId: string;
14
+ readonly timestampMs: number;
15
+ readonly channel: "user" | "assistant";
16
+ readonly audio: Int16Array;
17
+ readonly sampleRateHz: number;
18
+ }
19
+
20
+ export interface VapProbs {
21
+ readonly pShift: number;
22
+ readonly pBackchannel: number;
23
+ readonly pHold: number;
24
+ }
25
+
26
+ export type VapTranscriptObservation = Extract<
27
+ InteractionObservation,
28
+ { kind: "stt_partial" | "stt_final" }
29
+ >;
30
+
31
+ export type VapTranscriptFusion = (
32
+ probs: VapProbs,
33
+ transcript: VapTranscriptObservation,
34
+ ) => VapProbs;
35
+
36
+ export interface VapThresholds {
37
+ readonly shift: number;
38
+ readonly backchannel: number;
39
+ readonly take: number;
40
+ readonly hold: number;
41
+ }
42
+
43
+ export const DEFAULT_VAP_THRESHOLDS: VapThresholds = {
44
+ shift: 0.75,
45
+ backchannel: 0.5,
46
+ take: 0.6,
47
+ hold: 0.5,
48
+ };
49
+
50
+ export const ZERO_VAP_PROBS: VapProbs = { pShift: 0, pBackchannel: 0, pHold: 0 };
51
+
52
+ export interface VapDecisionContext {
53
+ readonly ttsActive: boolean;
54
+ readonly ttsContextId: string;
55
+ readonly delegateInFlight: boolean;
56
+ }
57
+
58
+ export function decideFromProbs(
59
+ probs: VapProbs,
60
+ ctx: VapDecisionContext,
61
+ thresholds: VapThresholds = DEFAULT_VAP_THRESHOLDS,
62
+ ): readonly InteractionDecision[] {
63
+ if (ctx.ttsActive && probs.pShift > thresholds.shift) {
64
+ return [{ kind: "interrupt", interruptedContextId: ctx.ttsContextId }];
65
+ }
66
+ if (probs.pBackchannel > thresholds.backchannel && ctx.delegateInFlight) {
67
+ return [{ kind: "backchannel", cue: "mhmm" }];
68
+ }
69
+ if (probs.pShift > thresholds.take) {
70
+ return [{ kind: "take_turn", confidence: probs.pShift }];
71
+ }
72
+ if (probs.pHold > thresholds.hold) {
73
+ return [{ kind: "hold" }];
74
+ }
75
+ return [{ kind: "keep_listening" }];
76
+ }
77
+
78
+ /** Conservative transcript evidence used by the C6 VAP+STT arm. */
79
+ export function semanticTranscriptFusion(
80
+ probs: VapProbs,
81
+ transcript: VapTranscriptObservation,
82
+ ): VapProbs {
83
+ const text = transcript.text.trim().replace(/\s+/g, " ");
84
+ if (!text) return probs;
85
+ const incomplete = /\b(and|but|or|so|because|if|when|while|although|since|unless|until|the|a|an|to|for|of|in|on|at|with|about|from|by|please|just|also|then|well|um|uh|i|we|you|my|your|our|this)$/i.test(text)
86
+ || text.endsWith(",");
87
+ if (incomplete) return { ...probs, pShift: Math.min(probs.pShift, 0.49) };
88
+ if (/[.!?]["')]*$/.test(text)) return { ...probs, pShift: Math.max(probs.pShift, 0.9) };
89
+ return probs;
90
+ }
91
+
92
+ const DEFAULT_MAX_SAMPLES = 16_000;
93
+
94
+ export class RollingFeatureBuffer {
95
+ private readonly storage: Float32Array;
96
+ private length = 0;
97
+ private writeIndex = 0;
98
+
99
+ constructor(private readonly maxSamples = DEFAULT_MAX_SAMPLES) {
100
+ this.storage = new Float32Array(maxSamples);
101
+ }
102
+
103
+ append(audio?: Int16Array): void {
104
+ if (audio && audio.length > 0) {
105
+ for (let i = 0; i < audio.length; i += 1) {
106
+ const sample = audio[i] ?? 0;
107
+ this.storage[this.writeIndex] = sample / 32768;
108
+ this.writeIndex = (this.writeIndex + 1) % this.maxSamples;
109
+ this.length = Math.min(this.length + 1, this.maxSamples);
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Returns an immutable chronological copy suitable for async inference. */
115
+ snapshot(): Float32Array {
116
+ const snapshot = new Float32Array(this.length);
117
+ const start = (this.writeIndex - this.length + this.maxSamples) % this.maxSamples;
118
+ const firstLength = Math.min(this.length, this.maxSamples - start);
119
+ snapshot.set(this.storage.subarray(start, start + firstLength));
120
+ if (firstLength < this.length) {
121
+ snapshot.set(this.storage.subarray(0, this.length - firstLength), firstLength);
122
+ }
123
+ return snapshot;
124
+ }
125
+
126
+ reset(): void {
127
+ this.length = 0;
128
+ this.writeIndex = 0;
129
+ }
130
+ }
package/src/workers.ts ADDED
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PluginConfig } from "@kuralle-syrinx/core";
4
+ import { optionalStringConfig } from "@kuralle-syrinx/core";
5
+ import {
6
+ RollingFeatureBuffer,
7
+ ZERO_VAP_PROBS,
8
+ type VapPredictor,
9
+ type VapPredictorFrame,
10
+ type VapProbs,
11
+ } from "./vap-policy.js";
12
+
13
+ type Ort = typeof import("onnxruntime-web");
14
+ type InferenceSession = import("onnxruntime-web").InferenceSession;
15
+
16
+ export class WorkersVapPredictor implements VapPredictor {
17
+ private ort: Ort | null = null;
18
+ private session: InferenceSession | null = null;
19
+
20
+ async initialize(cfg: PluginConfig = {}): Promise<void> {
21
+ this.ort = await import("onnxruntime-web");
22
+ this.session = await this.ort.InferenceSession.create(await readModelBytes(cfg), {
23
+ executionProviders: ["wasm"],
24
+ });
25
+ }
26
+
27
+ private readonly buffers = new Map<string, RollingFeatureBuffer>();
28
+ private readonly cachedProbs = new Map<string, VapProbs>();
29
+
30
+ async push(frame: VapPredictorFrame): Promise<VapProbs> {
31
+ if (!this.session || !this.ort) {
32
+ throw new Error("WorkersVapPredictor is not initialized");
33
+ }
34
+ if (frame.channel === "assistant") {
35
+ return this.cachedProbs.get(frame.contextId) ?? ZERO_VAP_PROBS;
36
+ }
37
+ const buffer = this.bufferFor(frame.contextId);
38
+ buffer.append(frame.audio);
39
+ const features = buffer.snapshot();
40
+ const output = await this.session.run({
41
+ features: new this.ort.Tensor("float32", features, [1, features.length]),
42
+ });
43
+ const probs = mapOnnxOutput(output);
44
+ this.cachedProbs.set(frame.contextId, probs);
45
+ return probs;
46
+ }
47
+
48
+ reset(contextId: string): void {
49
+ this.buffers.delete(contextId);
50
+ this.cachedProbs.delete(contextId);
51
+ }
52
+
53
+ async close(): Promise<void> {
54
+ this.session = null;
55
+ this.ort = null;
56
+ this.buffers.clear();
57
+ this.cachedProbs.clear();
58
+ }
59
+
60
+ private bufferFor(contextId: string): RollingFeatureBuffer {
61
+ let buffer = this.buffers.get(contextId);
62
+ if (!buffer) {
63
+ buffer = new RollingFeatureBuffer();
64
+ this.buffers.set(contextId, buffer);
65
+ }
66
+ return buffer;
67
+ }
68
+ }
69
+
70
+ async function readModelBytes(cfg: PluginConfig): Promise<Uint8Array> {
71
+ const bytes = cfg["model_bytes"];
72
+ if (bytes instanceof Uint8Array) return bytes;
73
+ if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
74
+ const url = optionalStringConfig(cfg, "model_url");
75
+ if (!url) {
76
+ throw new Error("WorkersVapPredictor requires model_bytes or model_url");
77
+ }
78
+ const response = await fetch(url);
79
+ if (!response.ok) {
80
+ throw new Error(`Failed to fetch VAP model from ${url}: ${response.status}`);
81
+ }
82
+ return new Uint8Array(await response.arrayBuffer());
83
+ }
84
+
85
+ function mapOnnxOutput(output: Record<string, import("onnxruntime-web").Tensor>): VapProbs {
86
+ const shift = readScalar(output["p_shift"] ?? output["pShift"] ?? output["shift"]);
87
+ const backchannel = readScalar(output["p_backchannel"] ?? output["pBackchannel"] ?? output["backchannel"]);
88
+ const hold = readScalar(output["p_hold"] ?? output["pHold"] ?? output["hold"]);
89
+ return { pShift: shift, pBackchannel: backchannel, pHold: hold };
90
+ }
91
+
92
+ function readScalar(tensor: import("onnxruntime-web").Tensor | undefined): number {
93
+ const value = tensor?.data?.[0];
94
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
95
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
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
+ }