@kuralle-syrinx/silero-vad 2.1.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
+
Binary file
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@kuralle-syrinx/silero-vad",
3
+ "version": "2.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./workers": "./src/workers.ts"
12
+ },
13
+ "dependencies": {
14
+ "onnxruntime-node": "1.24.3",
15
+ "onnxruntime-web": "1.24.3",
16
+ "@kuralle-syrinx/core": "2.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "typescript": "^5.7.0",
20
+ "vitest": "^2.1.0"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run"
25
+ }
26
+ }
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { PipelineBus } from "@kuralle-syrinx/core";
5
+
6
+ import { SileroVADPlugin } from "./index.js";
7
+
8
+ // Shared mutable state that the vi.mock factory can close over via vi.hoisted.
9
+ const mockControl = vi.hoisted(() => ({
10
+ confidence: 0.9,
11
+ stateCallArgs: [] as Float32Array[],
12
+ }));
13
+
14
+ vi.mock("onnxruntime-node", () => {
15
+ // Minimal Tensor stand-in — stores data so session.run() can read it back.
16
+ function MockTensor(
17
+ this: { data: unknown },
18
+ _type: string,
19
+ data: unknown,
20
+ _shape: unknown,
21
+ ) {
22
+ this.data = data;
23
+ }
24
+
25
+ const run = vi.fn(async (inputs: Record<string, { data: unknown }>) => {
26
+ const state = inputs["state"]?.data;
27
+ if (state instanceof Float32Array) {
28
+ mockControl.stateCallArgs.push(new Float32Array(state));
29
+ }
30
+ // Return a distinctive non-zero state so a subsequent reset is detectable.
31
+ return {
32
+ output: { data: [mockControl.confidence] },
33
+ stateN: { data: new Float32Array(2 * 1 * 128).fill(0.5) },
34
+ };
35
+ });
36
+
37
+ return {
38
+ InferenceSession: { create: vi.fn(async () => ({ run })) },
39
+ Tensor: MockTensor,
40
+ };
41
+ });
42
+
43
+ // ── helpers ────────────────────────────────────────────────────────────────
44
+
45
+ function makeBus(): { bus: PipelineBus; emitted: Array<{ kind: string }> } {
46
+ const emitted: Array<{ kind: string }> = [];
47
+ const bus = {
48
+ push(_route: unknown, pkt: unknown) {
49
+ emitted.push(pkt as { kind: string });
50
+ },
51
+ on(_event: string, _handler: unknown) {
52
+ return () => {};
53
+ },
54
+ } as unknown as PipelineBus;
55
+ return { bus, emitted };
56
+ }
57
+
58
+ /** 512-sample (32 ms) silent PCM frame — value irrelevant since model is mocked. */
59
+ function makePcmFrame(): Uint8Array {
60
+ return new Uint8Array(512 * 2);
61
+ }
62
+
63
+ async function initPlugin(
64
+ bus: PipelineBus,
65
+ config: Record<string, unknown> = {},
66
+ ): Promise<SileroVADPlugin> {
67
+ const plugin = new SileroVADPlugin();
68
+ await plugin.initialize(bus, { model_path: "/dev/null", ...config });
69
+ return plugin;
70
+ }
71
+
72
+ function countKind(emitted: Array<{ kind: string }>, kind: string): number {
73
+ return emitted.filter((p) => p.kind === kind).length;
74
+ }
75
+
76
+ function metricValue(
77
+ emitted: Array<{ kind: string; name?: string; value?: string }>,
78
+ name: string,
79
+ ): string | undefined {
80
+ return emitted.find((p) => p.kind === "metric.conversation" && p.name === name)?.value;
81
+ }
82
+
83
+ // ── tests ──────────────────────────────────────────────────────────────────
84
+
85
+ describe("SileroVADPlugin — G11: periodic state reset gating", () => {
86
+ beforeEach(() => {
87
+ vi.useFakeTimers();
88
+ vi.setSystemTime(0);
89
+ mockControl.confidence = 0.9;
90
+ mockControl.stateCallArgs.length = 0;
91
+ });
92
+
93
+ afterEach(() => {
94
+ vi.useRealTimers();
95
+ });
96
+
97
+ it("does not reset model state mid-speech when the 5 s timer elapses", async () => {
98
+ const { bus, emitted } = makeBus();
99
+ const plugin = await initPlugin(bus);
100
+
101
+ // Frame 1 (T=0): confidence=0.9 → speech_started, vadState=speaking.
102
+ // stateCallArgs[0] = all-zeros (initial state).
103
+ await plugin.processAudio(makePcmFrame(), "ctx-1");
104
+ expect(emitted.some((p) => p.kind === "vad.speech_started")).toBe(true);
105
+
106
+ // Advance past the 5 s reset boundary while still speaking.
107
+ vi.setSystemTime(6000);
108
+
109
+ // Frame 2 (T=6000): fix must skip the reset because vadState=speaking.
110
+ // stateCallArgs[1] = [0.5...] (state updated by frame 1 output).
111
+ // After this call, state stays [0.5...] (no reset).
112
+ await plugin.processAudio(makePcmFrame(), "ctx-1");
113
+
114
+ // Frame 3 (T=6000): if a reset had fired after frame 2, this call would
115
+ // receive all-zero state. With the fix it receives the non-zero value.
116
+ await plugin.processAudio(makePcmFrame(), "ctx-1");
117
+
118
+ expect(emitted.some((p) => p.kind === "vad.speech_ended")).toBe(false);
119
+
120
+ // State entering frame 3 must still carry the non-zero value from the mock
121
+ // (0.5), proving no mid-speech reset occurred.
122
+ const stateAtFrame3 = mockControl.stateCallArgs[2];
123
+ expect(stateAtFrame3).toBeDefined();
124
+ expect(stateAtFrame3!.some((v) => v !== 0)).toBe(true);
125
+
126
+ await plugin.close();
127
+ });
128
+
129
+ it("resets model state at the 5 s boundary once speech has ended", async () => {
130
+ const { bus, emitted } = makeBus();
131
+ const plugin = await initPlugin(bus);
132
+
133
+ // Frame 1 (T=0): speech starts.
134
+ mockControl.confidence = 0.9;
135
+ await plugin.processAudio(makePcmFrame(), "ctx-2");
136
+ expect(emitted.some((p) => p.kind === "vad.speech_started")).toBe(true);
137
+
138
+ // Switch to silence. silenceFrameTarget = ceil(200/32) = 7; speech ends when
139
+ // silenceFrames*32 >= minSilenceDurationMs(200) + speechPadMs(80) = 280 ms,
140
+ // i.e. after 9 consecutive silence frames (9*32=288 >= 280).
141
+ mockControl.confidence = 0.1;
142
+ for (let i = 0; i < 9; i++) {
143
+ await plugin.processAudio(makePcmFrame(), "ctx-2");
144
+ }
145
+ expect(emitted.some((p) => p.kind === "vad.speech_ended")).toBe(true);
146
+
147
+ // Time has not advanced yet; no reset should have fired (0 - 0 < 5000).
148
+ // Now advance past the reset window.
149
+ vi.setSystemTime(6000);
150
+
151
+ // Frame at T=6000 (not speaking): reset fires after this run call.
152
+ await plugin.processAudio(makePcmFrame(), "ctx-2");
153
+
154
+ // One more frame: should receive all-zero state because the reset zeroed it.
155
+ await plugin.processAudio(makePcmFrame(), "ctx-2");
156
+
157
+ const lastStateArg = mockControl.stateCallArgs.at(-1);
158
+ expect(lastStateArg).toBeDefined();
159
+ expect(lastStateArg!.every((v) => v === 0)).toBe(true);
160
+
161
+ await plugin.close();
162
+ });
163
+ });
164
+
165
+ describe("SileroVADPlugin — four-state VAD (VE-02)", () => {
166
+ beforeEach(() => {
167
+ mockControl.confidence = 0.9;
168
+ mockControl.stateCallArgs.length = 0;
169
+ });
170
+
171
+ it("vad_flap_rejected: sub-threshold speech burst does not emit speech_started", async () => {
172
+ const { bus, emitted } = makeBus();
173
+ const plugin = await initPlugin(bus, { speech_start_duration_ms: 96 });
174
+
175
+ mockControl.confidence = 0.9;
176
+ await plugin.processAudio(makePcmFrame(), "ctx-flap");
177
+
178
+ mockControl.confidence = 0.1;
179
+ await plugin.processAudio(makePcmFrame(), "ctx-flap");
180
+
181
+ expect(countKind(emitted, "vad.speech_started")).toBe(0);
182
+ expect(metricValue(emitted, "vad.start_delay_ms")).toBeUndefined();
183
+
184
+ await plugin.close();
185
+ });
186
+
187
+ it("vad_full_cycle: sustained speech then silence emits one start and one end", async () => {
188
+ const { bus, emitted } = makeBus();
189
+ const plugin = await initPlugin(bus);
190
+
191
+ mockControl.confidence = 0.9;
192
+ for (let i = 0; i < 4; i++) {
193
+ await plugin.processAudio(makePcmFrame(), "ctx-cycle");
194
+ }
195
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
196
+ expect(metricValue(emitted, "vad.start_delay_ms")).toBe("32");
197
+
198
+ mockControl.confidence = 0.1;
199
+ for (let i = 0; i < 9; i++) {
200
+ await plugin.processAudio(makePcmFrame(), "ctx-cycle");
201
+ }
202
+ expect(countKind(emitted, "vad.speech_ended")).toBe(1);
203
+ expect(metricValue(emitted, "vad.stop_hangover_ms")).toBe("288");
204
+
205
+ await plugin.close();
206
+ });
207
+
208
+ it("vad_resume_no_end: brief silence dip during speech does not emit speech_ended", async () => {
209
+ const { bus, emitted } = makeBus();
210
+ const plugin = await initPlugin(bus);
211
+
212
+ mockControl.confidence = 0.9;
213
+ await plugin.processAudio(makePcmFrame(), "ctx-resume");
214
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
215
+
216
+ mockControl.confidence = 0.1;
217
+ for (let i = 0; i < 3; i++) {
218
+ await plugin.processAudio(makePcmFrame(), "ctx-resume");
219
+ }
220
+ expect(countKind(emitted, "vad.speech_ended")).toBe(0);
221
+
222
+ mockControl.confidence = 0.9;
223
+ await plugin.processAudio(makePcmFrame(), "ctx-resume");
224
+ expect(countKind(emitted, "vad.speech_ended")).toBe(0);
225
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
226
+
227
+ await plugin.close();
228
+ });
229
+
230
+ it("default speech_start_duration_ms emits speech_started on the first speech frame", async () => {
231
+ const { bus, emitted } = makeBus();
232
+ const plugin = await initPlugin(bus);
233
+
234
+ mockControl.confidence = 0.9;
235
+ await plugin.processAudio(makePcmFrame(), "ctx-default");
236
+
237
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
238
+ expect(metricValue(emitted, "vad.start_delay_ms")).toBe("32");
239
+
240
+ await plugin.close();
241
+ });
242
+ });
243
+
244
+ describe("SileroVADPlugin — odd byteOffset PCM (browser buffer alignment)", () => {
245
+ beforeEach(() => {
246
+ mockControl.confidence = 0.9;
247
+ mockControl.stateCallArgs.length = 0;
248
+ });
249
+
250
+ it("processes a PCM frame whose byteOffset is odd without throwing", async () => {
251
+ const { bus, emitted } = makeBus();
252
+ const plugin = await initPlugin(bus);
253
+
254
+ // Inbound browser PCM is frequently a Uint8Array view into a pooled Node
255
+ // Buffer at an ODD byteOffset. `new Int16Array(buffer, oddOffset, …)` throws
256
+ // "start offset of Int16Array should be a multiple of 2" — this reproduces it.
257
+ const backing = new Uint8Array(512 * 2 + 1);
258
+ const oddOffsetFrame = backing.subarray(1); // byteOffset 1 (odd), even length
259
+ expect(oddOffsetFrame.byteOffset % 2).toBe(1);
260
+
261
+ await expect(plugin.processAudio(oddOffsetFrame, "ctx-odd")).resolves.toBeUndefined();
262
+ expect(emitted.some((p) => p.kind === "vad.error")).toBe(false);
263
+ // Reached the model and emitted a VAD verdict — proof the frame was processed.
264
+ expect(emitted.some((p) => p.kind === "vad.speech_started")).toBe(true);
265
+
266
+ await plugin.close();
267
+ });
268
+ });
269
+
270
+ describe("SileroVADPlugin — telephony saturation hardening", () => {
271
+ beforeEach(() => {
272
+ vi.useFakeTimers();
273
+ vi.setSystemTime(0);
274
+ mockControl.confidence = 0.9;
275
+ mockControl.stateCallArgs.length = 0;
276
+ });
277
+
278
+ afterEach(() => {
279
+ vi.useRealTimers();
280
+ });
281
+
282
+ async function frame(plugin: SileroVADPlugin, confidence: number): Promise<void> {
283
+ mockControl.confidence = confidence;
284
+ await plugin.processAudio(makePcmFrame(), "ctx-flap");
285
+ }
286
+
287
+ it("absorbs single-frame confidence spikes during stopping so speech still ends", async () => {
288
+ const { bus, emitted } = makeBus();
289
+ const plugin = await initPlugin(bus);
290
+
291
+ await frame(plugin, 0.9); // speech_started → speaking
292
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
293
+
294
+ // Saturation flap: real silence with an isolated spike in the middle.
295
+ for (const confidence of [0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2]) {
296
+ await frame(plugin, confidence);
297
+ }
298
+
299
+ expect(countKind(emitted, "vad.speech_ended")).toBe(1);
300
+ expect(metricValue(emitted, "vad.stop_hangover_ms")).toBeDefined();
301
+ });
302
+
303
+ it("two consecutive speech frames during stopping resume speaking (real speech is unaffected)", async () => {
304
+ const { bus, emitted } = makeBus();
305
+ const plugin = await initPlugin(bus);
306
+
307
+ await frame(plugin, 0.9); // speaking
308
+ for (const confidence of [0.2, 0.2, 0.2]) await frame(plugin, confidence); // stopping
309
+ for (const confidence of [0.9, 0.9]) await frame(plugin, confidence); // sustained speech → resume
310
+ expect(countKind(emitted, "vad.speech_ended")).toBe(0);
311
+
312
+ for (let i = 0; i < 9; i += 1) await frame(plugin, 0.2); // genuine end
313
+ expect(countKind(emitted, "vad.speech_ended")).toBe(1);
314
+ });
315
+
316
+ it("resets model state during prolonged continuous speech (saturation guard)", async () => {
317
+ const { bus, emitted } = makeBus();
318
+ const plugin = await initPlugin(bus);
319
+
320
+ await frame(plugin, 0.9); // speaking, state seeded
321
+ vi.setSystemTime(13_000); // past speaking_state_reset_interval_ms (12 s)
322
+ await frame(plugin, 0.9); // reset fires after this frame's inference
323
+ await frame(plugin, 0.9); // this inference must receive the zeroed state
324
+
325
+ expect(metricValue(emitted, "vad.state_reset_in_speech")).toBe("1");
326
+ const stateForThirdFrame = mockControl.stateCallArgs[2]!;
327
+ expect(stateForThirdFrame.every((value) => value === 0)).toBe(true);
328
+ });
329
+ });
package/src/index.ts ADDED
@@ -0,0 +1,314 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Silero VAD Plugin
4
+ //
5
+ // Runs the Silero ONNX model locally and emits VAD packets through PipelineBus.
6
+ // The model/state handling follows Pipecat's Silero analyzer and RapidAI's
7
+ // session-owned detector pattern: one model instance per session, state reset on
8
+ // lifecycle close, and 16 kHz LINEAR16 mono as the internal audio format.
9
+
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import type { PipelineBus } from "@kuralle-syrinx/core";
13
+ import {
14
+ ErrorCategory,
15
+ Route,
16
+ type PluginConfig,
17
+ type VadSpeechActivityPacket,
18
+ type VadSpeechEndedPacket,
19
+ type VadSpeechStartedPacket,
20
+ type VoiceErrorPacket,
21
+ type VoicePlugin,
22
+ isRecoverable,
23
+ optionalStringConfig,
24
+ } from "@kuralle-syrinx/core";
25
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
26
+
27
+ type Ort = typeof import("onnxruntime-node");
28
+ type InferenceSession = import("onnxruntime-node").InferenceSession;
29
+
30
+ const DEFAULT_MODEL_PATH = fileURLToPath(new URL("../models/silero_vad.onnx", import.meta.url));
31
+ const DEFAULT_SAMPLE_RATE = 16000;
32
+ const WINDOW_SAMPLES_16K = 512;
33
+ const CONTEXT_SAMPLES_16K = 64;
34
+
35
+ type VadState = "quiet" | "starting" | "speaking" | "stopping";
36
+
37
+ export class SileroVADPlugin implements VoicePlugin {
38
+ private bus: PipelineBus | null = null;
39
+ private session: InferenceSession | null = null;
40
+ private ort: Ort | null = null;
41
+ private state = new Float32Array(2 * 1 * 128);
42
+ private context = new Float32Array(CONTEXT_SAMPLES_16K);
43
+ private pendingSamples: number[] = [];
44
+ private vadState: VadState = "quiet";
45
+ private speechFrames = 0;
46
+ private confidenceThreshold = 0.5;
47
+ private minSilenceDurationMs = 200;
48
+ private speakingStateResetIntervalMs = 12_000;
49
+ private speechPadMs = 80;
50
+ private speechStartDurationMs = 32;
51
+ private sampleRate = DEFAULT_SAMPLE_RATE;
52
+ private silenceFrames = 0;
53
+ private stoppingSpikeFrames = 0;
54
+ private lastResetMs = 0;
55
+ private disposers: Array<() => void> = [];
56
+
57
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
58
+ this.bus = bus;
59
+ this.confidenceThreshold = readNumber(config, "threshold", 0.5);
60
+ this.minSilenceDurationMs = readNumber(config, "min_silence_duration_ms", 200);
61
+ this.speakingStateResetIntervalMs = readNumber(config, "speaking_state_reset_interval_ms", 12_000);
62
+ this.speechPadMs = readNumber(config, "speech_pad_ms", 80);
63
+ this.speechStartDurationMs = readNumber(config, "speech_start_duration_ms", 32);
64
+ this.sampleRate = readNumber(config, "sample_rate", DEFAULT_SAMPLE_RATE);
65
+ if (this.sampleRate !== 16000) {
66
+ throw new Error(`SileroVADPlugin requires 16 kHz PCM input, got ${String(this.sampleRate)} Hz`);
67
+ }
68
+
69
+ const modelPath = optionalStringConfig(config, "model_path") ?? DEFAULT_MODEL_PATH;
70
+ this.ort = await import("onnxruntime-node");
71
+ this.session = await this.ort.InferenceSession.create(modelPath, {
72
+ executionProviders: ["cpu"],
73
+ interOpNumThreads: 1,
74
+ intraOpNumThreads: 1,
75
+ });
76
+ this.resetModelState();
77
+
78
+ this.disposers.push(
79
+ bus.on("vad.audio", async (pkt: unknown) => {
80
+ const audioPkt = pkt as { audio: Uint8Array; contextId: string };
81
+ await this.processAudio(audioPkt.audio, audioPkt.contextId);
82
+ }),
83
+ );
84
+ }
85
+
86
+ async processAudio(audio: Uint8Array, contextId: string): Promise<void> {
87
+ if (!this.bus || !this.session || !this.ort) return;
88
+ if (audio.byteLength % 2 !== 0) {
89
+ this.emitError(contextId, new Error("VAD audio must be 16-bit PCM with even byte length"));
90
+ return;
91
+ }
92
+
93
+ // Offset-safe: inbound PCM is often a Uint8Array view into a pooled Node
94
+ // Buffer at an ODD byteOffset, so `new Int16Array(buffer, byteOffset, …)`
95
+ // throws "start offset of Int16Array should be a multiple of 2". The canonical
96
+ // helper reads via DataView and is offset-agnostic.
97
+ const samples = pcm16BytesToSamples(audio);
98
+ for (let i = 0; i < samples.length; i += 1) {
99
+ this.pendingSamples.push(samples[i]! / 32768);
100
+ }
101
+
102
+ while (this.pendingSamples.length >= WINDOW_SAMPLES_16K) {
103
+ const window = new Float32Array(WINDOW_SAMPLES_16K);
104
+ for (let i = 0; i < WINDOW_SAMPLES_16K; i += 1) {
105
+ window[i] = this.pendingSamples.shift()!;
106
+ }
107
+ const confidence = await this.runModel(window, contextId);
108
+ this.emitVadState(confidence, contextId);
109
+ }
110
+ }
111
+
112
+ async close(): Promise<void> {
113
+ for (const dispose of this.disposers.splice(0)) dispose();
114
+ this.bus = null;
115
+ this.session = null;
116
+ this.ort = null;
117
+ this.pendingSamples = [];
118
+ this.resetModelState();
119
+ }
120
+
121
+ private async runModel(window: Float32Array, contextId: string): Promise<number> {
122
+ if (!this.session || !this.ort) return 0;
123
+
124
+ const input = new Float32Array(CONTEXT_SAMPLES_16K + WINDOW_SAMPLES_16K);
125
+ input.set(this.context, 0);
126
+ input.set(window, CONTEXT_SAMPLES_16K);
127
+
128
+ try {
129
+ const output = await this.session.run({
130
+ input: new this.ort.Tensor("float32", input, [1, input.length]),
131
+ state: new this.ort.Tensor("float32", this.state, [2, 1, 128]),
132
+ sr: new this.ort.Tensor("int64", BigInt64Array.from([BigInt(this.sampleRate)]), []),
133
+ });
134
+
135
+ const probability = output["output"]?.data?.[0];
136
+ const nextState = output["stateN"]?.data;
137
+ if (nextState instanceof Float32Array) {
138
+ this.state = new Float32Array(nextState);
139
+ }
140
+ this.context = input.slice(-CONTEXT_SAMPLES_16K);
141
+
142
+ const now = Date.now();
143
+ if (this.vadState === "quiet" && now - this.lastResetMs >= 5000) {
144
+ this.resetModelState();
145
+ }
146
+
147
+ return typeof probability === "number" ? probability : 0;
148
+ } catch (err) {
149
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
150
+ return 0;
151
+ }
152
+ }
153
+
154
+ private emitVadState(confidence: number, contextId: string): void {
155
+ if (!this.bus) return;
156
+
157
+ const now = Date.now();
158
+ const isSpeech = confidence >= this.confidenceThreshold;
159
+ const silenceFrameTarget = Math.max(1, Math.ceil(this.minSilenceDurationMs / 32));
160
+
161
+ switch (this.vadState) {
162
+ case "quiet":
163
+ if (isSpeech) {
164
+ this.vadState = "starting";
165
+ this.speechFrames = 1;
166
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
167
+ }
168
+ break;
169
+
170
+ case "starting":
171
+ if (isSpeech) {
172
+ if (!this.promoteStartingToSpeakingIfReady(confidence, contextId, now)) {
173
+ this.speechFrames += 1;
174
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
175
+ }
176
+ } else {
177
+ this.vadState = "quiet";
178
+ this.speechFrames = 0;
179
+ }
180
+ break;
181
+
182
+ case "speaking":
183
+ if (isSpeech) {
184
+ // Silero v5 LSTM state saturates on long continuous segments
185
+ // (telephony monologues): confidence then flaps high through genuine
186
+ // silence and the end never fires. Periodically reset model state
187
+ // mid-speech; the spike debounce in "stopping" makes the brief
188
+ // post-reset confidence dip harmless for real speech.
189
+ if (now - this.lastResetMs >= this.speakingStateResetIntervalMs) {
190
+ this.resetModelState();
191
+ this.bus.push(Route.Main, {
192
+ kind: "metric.conversation",
193
+ contextId,
194
+ timestampMs: now,
195
+ name: "vad.state_reset_in_speech",
196
+ value: "1",
197
+ });
198
+ }
199
+ this.emitSpeechActivity(contextId, now);
200
+ } else {
201
+ this.vadState = "stopping";
202
+ this.silenceFrames = 1;
203
+ this.stoppingSpikeFrames = 0;
204
+ }
205
+ break;
206
+
207
+ case "stopping":
208
+ if (isSpeech) {
209
+ // Single-frame confidence spikes are a known Silero failure mode on
210
+ // long telephony segments (state saturation flaps high through real
211
+ // silence). Require sustained speech to leave "stopping" so one
212
+ // spike cannot reset the silence countdown forever.
213
+ this.stoppingSpikeFrames += 1;
214
+ if (this.stoppingSpikeFrames >= 2) {
215
+ this.vadState = "speaking";
216
+ this.silenceFrames = 0;
217
+ this.stoppingSpikeFrames = 0;
218
+ this.emitSpeechActivity(contextId, now);
219
+ }
220
+ break;
221
+ }
222
+ this.stoppingSpikeFrames = 0;
223
+ this.silenceFrames += 1;
224
+ if (this.silenceFrames * 32 < this.minSilenceDurationMs + this.speechPadMs) break;
225
+ if (this.silenceFrames < silenceFrameTarget) break;
226
+
227
+ {
228
+ const hangoverMs = this.silenceFrames * 32;
229
+ this.vadState = "quiet";
230
+ this.silenceFrames = 0;
231
+ this.speechFrames = 0;
232
+ this.stoppingSpikeFrames = 0;
233
+ const ended: VadSpeechEndedPacket = {
234
+ kind: "vad.speech_ended",
235
+ contextId,
236
+ timestampMs: now,
237
+ };
238
+ this.bus.push(Route.Main, ended);
239
+ this.bus.push(Route.Main, {
240
+ kind: "metric.conversation",
241
+ contextId,
242
+ timestampMs: now,
243
+ name: "vad.stop_hangover_ms",
244
+ value: String(hangoverMs),
245
+ });
246
+ }
247
+ break;
248
+ }
249
+ }
250
+
251
+ private promoteStartingToSpeakingIfReady(
252
+ confidence: number,
253
+ contextId: string,
254
+ now: number,
255
+ ): boolean {
256
+ if (!this.bus || this.vadState !== "starting") return false;
257
+ if (this.speechFrames * 32 < this.speechStartDurationMs) return false;
258
+
259
+ const startDelayMs = this.speechFrames * 32;
260
+ this.vadState = "speaking";
261
+ this.speechFrames = 0;
262
+ const started: VadSpeechStartedPacket = {
263
+ kind: "vad.speech_started",
264
+ contextId,
265
+ timestampMs: now,
266
+ confidence,
267
+ };
268
+ this.bus.push(Route.Main, started);
269
+ this.bus.push(Route.Main, {
270
+ kind: "metric.conversation",
271
+ contextId,
272
+ timestampMs: now,
273
+ name: "vad.start_delay_ms",
274
+ value: String(startDelayMs),
275
+ });
276
+ this.emitSpeechActivity(contextId, now);
277
+ return true;
278
+ }
279
+
280
+ private emitSpeechActivity(contextId: string, now: number): void {
281
+ if (!this.bus) return;
282
+ const activity: VadSpeechActivityPacket = {
283
+ kind: "vad.speech_activity",
284
+ contextId,
285
+ timestampMs: now,
286
+ isAsync: true,
287
+ };
288
+ this.bus.push(Route.Main, activity);
289
+ }
290
+
291
+ private resetModelState(): void {
292
+ this.state = new Float32Array(2 * 1 * 128);
293
+ this.context = new Float32Array(CONTEXT_SAMPLES_16K);
294
+ this.lastResetMs = Date.now();
295
+ }
296
+
297
+ private emitError(contextId: string, err: Error): void {
298
+ const packet: VoiceErrorPacket = {
299
+ kind: "vad.error",
300
+ contextId,
301
+ timestampMs: Date.now(),
302
+ component: "vad",
303
+ category: ErrorCategory.InvalidInput,
304
+ cause: err,
305
+ isRecoverable: isRecoverable(ErrorCategory.InvalidInput),
306
+ };
307
+ this.bus?.push(Route.Critical, packet);
308
+ }
309
+ }
310
+
311
+ function readNumber(config: PluginConfig, key: string, fallback: number): number {
312
+ const value = config[key];
313
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
314
+ }
package/src/workers.ts ADDED
@@ -0,0 +1,272 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PipelineBus } from "@kuralle-syrinx/core";
4
+ import {
5
+ ErrorCategory,
6
+ Route,
7
+ type PluginConfig,
8
+ type VadSpeechActivityPacket,
9
+ type VadSpeechEndedPacket,
10
+ type VadSpeechStartedPacket,
11
+ type VoiceErrorPacket,
12
+ type VoicePlugin,
13
+ isRecoverable,
14
+ optionalStringConfig,
15
+ } from "@kuralle-syrinx/core";
16
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
17
+
18
+ type Ort = typeof import("onnxruntime-web");
19
+ type InferenceSession = import("onnxruntime-web").InferenceSession;
20
+
21
+ const DEFAULT_SAMPLE_RATE = 16000;
22
+ const WINDOW_SAMPLES_16K = 512;
23
+ const CONTEXT_SAMPLES_16K = 64;
24
+
25
+ type VadState = "quiet" | "starting" | "speaking" | "stopping";
26
+
27
+ export class SileroVADPlugin implements VoicePlugin {
28
+ private bus: PipelineBus | null = null;
29
+ private session: InferenceSession | null = null;
30
+ private ort: Ort | null = null;
31
+ private state = new Float32Array(2 * 1 * 128);
32
+ private context = new Float32Array(CONTEXT_SAMPLES_16K);
33
+ private pendingSamples: number[] = [];
34
+ private vadState: VadState = "quiet";
35
+ private speechFrames = 0;
36
+ private confidenceThreshold = 0.5;
37
+ private minSilenceDurationMs = 200;
38
+ private speechPadMs = 80;
39
+ private speechStartDurationMs = 32;
40
+ private sampleRate = DEFAULT_SAMPLE_RATE;
41
+ private silenceFrames = 0;
42
+ private lastResetMs = 0;
43
+ private disposers: Array<() => void> = [];
44
+
45
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
46
+ this.bus = bus;
47
+ this.confidenceThreshold = readNumber(config, "threshold", 0.5);
48
+ this.minSilenceDurationMs = readNumber(config, "min_silence_duration_ms", 200);
49
+ this.speechPadMs = readNumber(config, "speech_pad_ms", 80);
50
+ this.speechStartDurationMs = readNumber(config, "speech_start_duration_ms", 32);
51
+ this.sampleRate = readNumber(config, "sample_rate", DEFAULT_SAMPLE_RATE);
52
+ if (this.sampleRate !== 16000) {
53
+ throw new Error(`SileroVADPlugin requires 16 kHz PCM input, got ${String(this.sampleRate)} Hz`);
54
+ }
55
+
56
+ this.ort = await import("onnxruntime-web");
57
+ this.session = await this.ort.InferenceSession.create(await readModelBytes(config), {
58
+ executionProviders: ["wasm"],
59
+ });
60
+ this.resetModelState();
61
+
62
+ this.disposers.push(
63
+ bus.on("vad.audio", async (pkt: unknown) => {
64
+ const audioPkt = pkt as { audio: Uint8Array; contextId: string };
65
+ await this.processAudio(audioPkt.audio, audioPkt.contextId);
66
+ }),
67
+ );
68
+ }
69
+
70
+ async processAudio(audio: Uint8Array, contextId: string): Promise<void> {
71
+ if (!this.bus || !this.session || !this.ort) return;
72
+ if (audio.byteLength % 2 !== 0) {
73
+ this.emitError(contextId, new Error("VAD audio must be 16-bit PCM with even byte length"));
74
+ return;
75
+ }
76
+
77
+ const samples = pcm16BytesToSamples(audio);
78
+ for (let i = 0; i < samples.length; i += 1) {
79
+ this.pendingSamples.push(samples[i]! / 32768);
80
+ }
81
+
82
+ while (this.pendingSamples.length >= WINDOW_SAMPLES_16K) {
83
+ const window = new Float32Array(WINDOW_SAMPLES_16K);
84
+ for (let i = 0; i < WINDOW_SAMPLES_16K; i += 1) {
85
+ window[i] = this.pendingSamples.shift()!;
86
+ }
87
+ const confidence = await this.runModel(window, contextId);
88
+ this.emitVadState(confidence, contextId);
89
+ }
90
+ }
91
+
92
+ async close(): Promise<void> {
93
+ for (const dispose of this.disposers.splice(0)) dispose();
94
+ this.bus = null;
95
+ this.session = null;
96
+ this.ort = null;
97
+ this.pendingSamples = [];
98
+ this.resetModelState();
99
+ }
100
+
101
+ private async runModel(window: Float32Array, contextId: string): Promise<number> {
102
+ if (!this.session || !this.ort) return 0;
103
+
104
+ const input = new Float32Array(CONTEXT_SAMPLES_16K + WINDOW_SAMPLES_16K);
105
+ input.set(this.context, 0);
106
+ input.set(window, CONTEXT_SAMPLES_16K);
107
+
108
+ try {
109
+ const output = await this.session.run({
110
+ input: new this.ort.Tensor("float32", input, [1, input.length]),
111
+ state: new this.ort.Tensor("float32", this.state, [2, 1, 128]),
112
+ sr: new this.ort.Tensor("int64", BigInt64Array.from([BigInt(this.sampleRate)]), []),
113
+ });
114
+
115
+ const probability = output["output"]?.data?.[0];
116
+ const nextState = output["stateN"]?.data;
117
+ if (nextState instanceof Float32Array) {
118
+ this.state = new Float32Array(nextState);
119
+ }
120
+ this.context = input.slice(-CONTEXT_SAMPLES_16K);
121
+
122
+ const now = Date.now();
123
+ if (this.vadState === "quiet" && now - this.lastResetMs >= 5000) {
124
+ this.resetModelState();
125
+ }
126
+
127
+ return typeof probability === "number" ? probability : 0;
128
+ } catch (err) {
129
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
130
+ return 0;
131
+ }
132
+ }
133
+
134
+ private emitVadState(confidence: number, contextId: string): void {
135
+ if (!this.bus) return;
136
+
137
+ const now = Date.now();
138
+ const isSpeech = confidence >= this.confidenceThreshold;
139
+ const silenceFrameTarget = Math.max(1, Math.ceil(this.minSilenceDurationMs / 32));
140
+
141
+ switch (this.vadState) {
142
+ case "quiet":
143
+ if (isSpeech) {
144
+ this.vadState = "starting";
145
+ this.speechFrames = 1;
146
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
147
+ }
148
+ break;
149
+ case "starting":
150
+ if (isSpeech) {
151
+ if (!this.promoteStartingToSpeakingIfReady(confidence, contextId, now)) {
152
+ this.speechFrames += 1;
153
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
154
+ }
155
+ } else {
156
+ this.vadState = "quiet";
157
+ this.speechFrames = 0;
158
+ }
159
+ break;
160
+ case "speaking":
161
+ if (isSpeech) {
162
+ this.emitSpeechActivity(contextId, now);
163
+ } else {
164
+ this.vadState = "stopping";
165
+ this.silenceFrames = 1;
166
+ }
167
+ break;
168
+ case "stopping":
169
+ if (isSpeech) {
170
+ this.vadState = "speaking";
171
+ this.silenceFrames = 0;
172
+ this.emitSpeechActivity(contextId, now);
173
+ } else {
174
+ this.silenceFrames += 1;
175
+ if (this.silenceFrames * 32 < this.minSilenceDurationMs + this.speechPadMs) break;
176
+ if (this.silenceFrames < silenceFrameTarget) break;
177
+
178
+ const hangoverMs = this.silenceFrames * 32;
179
+ this.vadState = "quiet";
180
+ this.silenceFrames = 0;
181
+ this.speechFrames = 0;
182
+ const ended: VadSpeechEndedPacket = {
183
+ kind: "vad.speech_ended",
184
+ contextId,
185
+ timestampMs: now,
186
+ };
187
+ this.bus.push(Route.Main, ended);
188
+ this.bus.push(Route.Main, {
189
+ kind: "metric.conversation",
190
+ contextId,
191
+ timestampMs: now,
192
+ name: "vad.stop_hangover_ms",
193
+ value: String(hangoverMs),
194
+ });
195
+ }
196
+ break;
197
+ }
198
+ }
199
+
200
+ private promoteStartingToSpeakingIfReady(confidence: number, contextId: string, now: number): boolean {
201
+ if (!this.bus || this.vadState !== "starting") return false;
202
+ if (this.speechFrames * 32 < this.speechStartDurationMs) return false;
203
+
204
+ const startDelayMs = this.speechFrames * 32;
205
+ this.vadState = "speaking";
206
+ this.speechFrames = 0;
207
+ const started: VadSpeechStartedPacket = {
208
+ kind: "vad.speech_started",
209
+ contextId,
210
+ timestampMs: now,
211
+ confidence,
212
+ };
213
+ this.bus.push(Route.Main, started);
214
+ this.bus.push(Route.Main, {
215
+ kind: "metric.conversation",
216
+ contextId,
217
+ timestampMs: now,
218
+ name: "vad.start_delay_ms",
219
+ value: String(startDelayMs),
220
+ });
221
+ this.emitSpeechActivity(contextId, now);
222
+ return true;
223
+ }
224
+
225
+ private emitSpeechActivity(contextId: string, now: number): void {
226
+ if (!this.bus) return;
227
+ const activity: VadSpeechActivityPacket = {
228
+ kind: "vad.speech_activity",
229
+ contextId,
230
+ timestampMs: now,
231
+ isAsync: true,
232
+ };
233
+ this.bus.push(Route.Main, activity);
234
+ }
235
+
236
+ private resetModelState(): void {
237
+ this.state = new Float32Array(2 * 1 * 128);
238
+ this.context = new Float32Array(CONTEXT_SAMPLES_16K);
239
+ this.lastResetMs = Date.now();
240
+ }
241
+
242
+ private emitError(contextId: string, err: Error): void {
243
+ const packet: VoiceErrorPacket = {
244
+ kind: "vad.error",
245
+ contextId,
246
+ timestampMs: Date.now(),
247
+ component: "vad",
248
+ category: ErrorCategory.InvalidInput,
249
+ cause: err,
250
+ isRecoverable: isRecoverable(ErrorCategory.InvalidInput),
251
+ };
252
+ this.bus?.push(Route.Critical, packet);
253
+ }
254
+ }
255
+
256
+ async function readModelBytes(config: PluginConfig): Promise<Uint8Array> {
257
+ const bytes = config["model_bytes"];
258
+ if (bytes instanceof Uint8Array) return bytes;
259
+ if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
260
+ const modelUrl = optionalStringConfig(config, "model_url");
261
+ if (!modelUrl) {
262
+ throw new Error("SileroVADPlugin on Workers requires model_bytes or model_url");
263
+ }
264
+ const response = await fetch(modelUrl);
265
+ if (!response.ok) throw new Error(`Failed to fetch Silero model: ${String(response.status)}`);
266
+ return new Uint8Array(await response.arrayBuffer());
267
+ }
268
+
269
+ function readNumber(config: PluginConfig, key: string, fallback: number): number {
270
+ const value = config[key];
271
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
272
+ }
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
+ }