@kuralle-syrinx/silero-vad 4.4.1 → 4.5.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.
@@ -0,0 +1,20 @@
1
+ import type { PipelineBus } from "@kuralle-syrinx/core";
2
+ import { type PluginConfig, type VoicePlugin } from "@kuralle-syrinx/core";
3
+ export declare class SileroVADPlugin implements VoicePlugin {
4
+ private bus;
5
+ private session;
6
+ private ort;
7
+ private state;
8
+ private context;
9
+ private readonly windows;
10
+ private machine;
11
+ private sampleRate;
12
+ private disposers;
13
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
14
+ processAudio(audio: Uint8Array, contextId: string): Promise<void>;
15
+ close(): Promise<void>;
16
+ private runModel;
17
+ private resetModelState;
18
+ private emitError;
19
+ }
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAGjB,MAAM,sBAAsB,CAAC;AAc9B,qBAAa,eAAgB,YAAW,WAAW;IACjD,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,GAAG,CAAoB;IAC/B,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2B;IACnD,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAAyB;IAEpC,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBjE,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YASd,QAAQ;IA4BtB,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,SAAS;CAYlB"}
package/dist/index.js ADDED
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Silero VAD Plugin (Node, onnxruntime-node)
4
+ //
5
+ // Runs the Silero ONNX model locally and emits VAD packets through PipelineBus.
6
+ // All turn/state decisions live in the shared SileroVadStateMachine
7
+ // (vad-state-machine.ts) — this file only owns the Node-specific ONNX runtime
8
+ // and on-disk model loading. The Workers variant (workers.ts) shares the same
9
+ // machine with onnxruntime-web, so the two can never drift again.
10
+ import { fileURLToPath } from "node:url";
11
+ import { ErrorCategory, Route, isRecoverable, optionalStringConfig, } from "@kuralle-syrinx/core";
12
+ import { CONTEXT_SAMPLES_16K, Pcm16WindowBuffer, SileroVadStateMachine, readVadTuning, } from "./vad-state-machine.js";
13
+ const DEFAULT_MODEL_PATH = fileURLToPath(new URL("../models/silero_vad.onnx", import.meta.url));
14
+ const DEFAULT_SAMPLE_RATE = 16000;
15
+ export class SileroVADPlugin {
16
+ bus = null;
17
+ session = null;
18
+ ort = null;
19
+ state = new Float32Array(2 * 1 * 128);
20
+ context = new Float32Array(CONTEXT_SAMPLES_16K);
21
+ windows = new Pcm16WindowBuffer();
22
+ machine = null;
23
+ sampleRate = DEFAULT_SAMPLE_RATE;
24
+ disposers = [];
25
+ async initialize(bus, config) {
26
+ this.bus = bus;
27
+ this.sampleRate = readSampleRate(config);
28
+ this.machine = new SileroVadStateMachine(bus, readVadTuning(config), () => this.resetModelState());
29
+ const modelPath = optionalStringConfig(config, "model_path") ?? DEFAULT_MODEL_PATH;
30
+ this.ort = await import("onnxruntime-node");
31
+ this.session = await this.ort.InferenceSession.create(modelPath, {
32
+ executionProviders: ["cpu"],
33
+ interOpNumThreads: 1,
34
+ intraOpNumThreads: 1,
35
+ });
36
+ this.resetModelState();
37
+ this.machine.noteModelReset();
38
+ this.disposers.push(bus.on("vad.audio", async (pkt) => {
39
+ const audioPkt = pkt;
40
+ await this.processAudio(audioPkt.audio, audioPkt.contextId);
41
+ }));
42
+ }
43
+ async processAudio(audio, contextId) {
44
+ if (!this.bus || !this.session || !this.ort || !this.machine)
45
+ return;
46
+ if (audio.byteLength % 2 !== 0) {
47
+ this.emitError(contextId, new Error("VAD audio must be 16-bit PCM with even byte length"));
48
+ return;
49
+ }
50
+ // Offset-safe: inbound PCM is often a Uint8Array view into a pooled Node
51
+ // Buffer at an ODD byteOffset — the shared buffer reads via DataView.
52
+ this.windows.push(audio);
53
+ for (let window = this.windows.next(); window; window = this.windows.next()) {
54
+ const confidence = await this.runModel(window, contextId);
55
+ this.machine.observe(confidence, contextId);
56
+ }
57
+ }
58
+ async close() {
59
+ for (const dispose of this.disposers.splice(0))
60
+ dispose();
61
+ this.bus = null;
62
+ this.session = null;
63
+ this.ort = null;
64
+ this.windows.clear();
65
+ this.resetModelState();
66
+ }
67
+ async runModel(window, contextId) {
68
+ if (!this.session || !this.ort)
69
+ return 0;
70
+ const input = new Float32Array(CONTEXT_SAMPLES_16K + window.length);
71
+ input.set(this.context, 0);
72
+ input.set(window, CONTEXT_SAMPLES_16K);
73
+ try {
74
+ const output = await this.session.run({
75
+ input: new this.ort.Tensor("float32", input, [1, input.length]),
76
+ state: new this.ort.Tensor("float32", this.state, [2, 1, 128]),
77
+ sr: new this.ort.Tensor("int64", BigInt64Array.from([BigInt(this.sampleRate)]), []),
78
+ });
79
+ const probability = output["output"]?.data?.[0];
80
+ const nextState = output["stateN"]?.data;
81
+ if (nextState instanceof Float32Array) {
82
+ this.state = new Float32Array(nextState);
83
+ }
84
+ this.context = input.slice(-CONTEXT_SAMPLES_16K);
85
+ return typeof probability === "number" ? probability : 0;
86
+ }
87
+ catch (err) {
88
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
89
+ return 0;
90
+ }
91
+ }
92
+ resetModelState() {
93
+ this.state = new Float32Array(2 * 1 * 128);
94
+ this.context = new Float32Array(CONTEXT_SAMPLES_16K);
95
+ }
96
+ emitError(contextId, err) {
97
+ const packet = {
98
+ kind: "vad.error",
99
+ contextId,
100
+ timestampMs: Date.now(),
101
+ component: "vad",
102
+ category: ErrorCategory.InvalidInput,
103
+ cause: err,
104
+ isRecoverable: isRecoverable(ErrorCategory.InvalidInput),
105
+ };
106
+ this.bus?.push(Route.Critical, packet);
107
+ }
108
+ }
109
+ function readSampleRate(config) {
110
+ const value = config["sample_rate"];
111
+ const sampleRate = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_SAMPLE_RATE;
112
+ if (sampleRate !== 16000) {
113
+ throw new Error(`SileroVADPlugin requires 16 kHz PCM input, got ${String(sampleRate)} Hz`);
114
+ }
115
+ return sampleRate;
116
+ }
117
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import { type PipelineBus, type PluginConfig } from "@kuralle-syrinx/core";
2
+ export declare const WINDOW_SAMPLES_16K = 512;
3
+ export declare const CONTEXT_SAMPLES_16K = 64;
4
+ export interface VadTuning {
5
+ readonly confidenceThreshold: number;
6
+ readonly minSilenceDurationMs: number;
7
+ readonly speechPadMs: number;
8
+ readonly speechStartDurationMs: number;
9
+ readonly speakingStateResetIntervalMs: number;
10
+ }
11
+ export declare function readVadTuning(config: PluginConfig): VadTuning;
12
+ /** Accumulates inbound PCM16 bytes and yields normalized 512-sample model windows. */
13
+ export declare class Pcm16WindowBuffer {
14
+ private pendingSamples;
15
+ push(audio: Uint8Array): void;
16
+ next(): Float32Array | null;
17
+ clear(): void;
18
+ }
19
+ export declare class SileroVadStateMachine {
20
+ private readonly bus;
21
+ private readonly tuning;
22
+ /** Zero the runtime's model state/context tensors; the machine owns when. */
23
+ private readonly onModelReset;
24
+ private vadState;
25
+ private speechFrames;
26
+ private silenceFrames;
27
+ private stoppingSpikeFrames;
28
+ private lastResetMs;
29
+ constructor(bus: PipelineBus, tuning: VadTuning,
30
+ /** Zero the runtime's model state/context tensors; the machine owns when. */
31
+ onModelReset: () => void);
32
+ noteModelReset(): void;
33
+ observe(confidence: number, contextId: string): void;
34
+ private promoteStartingToSpeakingIfReady;
35
+ private emitSpeechActivity;
36
+ }
37
+ //# sourceMappingURL=vad-state-machine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vad-state-machine.d.ts","sourceRoot":"","sources":["../src/vad-state-machine.ts"],"names":[],"mappings":"AASA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAC;AAG9B,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAMtC,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,4BAA4B,EAAE,MAAM,CAAC;CAC/C;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,CAQ7D;AAED,sFAAsF;AACtF,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,cAAc,CAAgB;IAEtC,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAO7B,IAAI,IAAI,YAAY,GAAG,IAAI;IAS3B,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,qBAAqB;IAQ9B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,6EAA6E;IAC7E,OAAO,CAAC,QAAQ,CAAC,YAAY;IAV/B,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,WAAW,CAAc;gBAGd,GAAG,EAAE,WAAW,EAChB,MAAM,EAAE,SAAS;IAClC,6EAA6E;IAC5D,YAAY,EAAE,MAAM,IAAI;IAG3C,cAAc,IAAI,IAAI;IAItB,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAuGpD,OAAO,CAAC,gCAAgC;IA6BxC,OAAO,CAAC,kBAAkB;CAS3B"}
@@ -0,0 +1,202 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Shared Silero VAD turn state machine + PCM windowing — the single source of
4
+ // truth for both the Node (onnxruntime-node) and Workers (onnxruntime-web)
5
+ // plugins. The two runtimes differ only in how the ONNX session is created and
6
+ // where the model bytes come from; everything that decides "is the user
7
+ // speaking" lives here so the variants can never drift again (v2.1.0 shipped
8
+ // the telephony saturation hardening in the Node copy only).
9
+ import { Route, } from "@kuralle-syrinx/core";
10
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
11
+ export const WINDOW_SAMPLES_16K = 512;
12
+ export const CONTEXT_SAMPLES_16K = 64;
13
+ const QUIET_MODEL_RESET_INTERVAL_MS = 5000;
14
+ export function readVadTuning(config) {
15
+ return {
16
+ confidenceThreshold: readNumber(config, "threshold", 0.5),
17
+ minSilenceDurationMs: readNumber(config, "min_silence_duration_ms", 200),
18
+ speechPadMs: readNumber(config, "speech_pad_ms", 80),
19
+ speechStartDurationMs: readNumber(config, "speech_start_duration_ms", 32),
20
+ speakingStateResetIntervalMs: readNumber(config, "speaking_state_reset_interval_ms", 12_000),
21
+ };
22
+ }
23
+ /** Accumulates inbound PCM16 bytes and yields normalized 512-sample model windows. */
24
+ export class Pcm16WindowBuffer {
25
+ pendingSamples = [];
26
+ push(audio) {
27
+ const samples = pcm16BytesToSamples(audio);
28
+ for (let i = 0; i < samples.length; i += 1) {
29
+ this.pendingSamples.push(samples[i] / 32768);
30
+ }
31
+ }
32
+ next() {
33
+ if (this.pendingSamples.length < WINDOW_SAMPLES_16K)
34
+ return null;
35
+ const window = new Float32Array(WINDOW_SAMPLES_16K);
36
+ for (let i = 0; i < WINDOW_SAMPLES_16K; i += 1) {
37
+ window[i] = this.pendingSamples.shift();
38
+ }
39
+ return window;
40
+ }
41
+ clear() {
42
+ this.pendingSamples = [];
43
+ }
44
+ }
45
+ export class SileroVadStateMachine {
46
+ bus;
47
+ tuning;
48
+ onModelReset;
49
+ vadState = "quiet";
50
+ speechFrames = 0;
51
+ silenceFrames = 0;
52
+ stoppingSpikeFrames = 0;
53
+ lastResetMs = Date.now();
54
+ constructor(bus, tuning,
55
+ /** Zero the runtime's model state/context tensors; the machine owns when. */
56
+ onModelReset) {
57
+ this.bus = bus;
58
+ this.tuning = tuning;
59
+ this.onModelReset = onModelReset;
60
+ }
61
+ noteModelReset() {
62
+ this.lastResetMs = Date.now();
63
+ }
64
+ observe(confidence, contextId) {
65
+ const now = Date.now();
66
+ // Long-quiet model reset: keep the LSTM fresh between utterances.
67
+ if (this.vadState === "quiet" && now - this.lastResetMs >= QUIET_MODEL_RESET_INTERVAL_MS) {
68
+ this.onModelReset();
69
+ this.lastResetMs = now;
70
+ }
71
+ const isSpeech = confidence >= this.tuning.confidenceThreshold;
72
+ const silenceFrameTarget = Math.max(1, Math.ceil(this.tuning.minSilenceDurationMs / 32));
73
+ switch (this.vadState) {
74
+ case "quiet":
75
+ if (isSpeech) {
76
+ this.vadState = "starting";
77
+ this.speechFrames = 1;
78
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
79
+ }
80
+ break;
81
+ case "starting":
82
+ if (isSpeech) {
83
+ if (!this.promoteStartingToSpeakingIfReady(confidence, contextId, now)) {
84
+ this.speechFrames += 1;
85
+ this.promoteStartingToSpeakingIfReady(confidence, contextId, now);
86
+ }
87
+ }
88
+ else {
89
+ this.vadState = "quiet";
90
+ this.speechFrames = 0;
91
+ }
92
+ break;
93
+ case "speaking":
94
+ if (isSpeech) {
95
+ // Silero v5 LSTM state saturates on long continuous segments
96
+ // (telephony monologues): confidence then flaps high through genuine
97
+ // silence and the end never fires. Periodically reset model state
98
+ // mid-speech; the spike debounce in "stopping" makes the brief
99
+ // post-reset confidence dip harmless for real speech.
100
+ if (now - this.lastResetMs >= this.tuning.speakingStateResetIntervalMs) {
101
+ this.onModelReset();
102
+ this.lastResetMs = now;
103
+ this.bus.push(Route.Main, {
104
+ kind: "metric.conversation",
105
+ contextId,
106
+ timestampMs: now,
107
+ name: "vad.state_reset_in_speech",
108
+ value: "1",
109
+ });
110
+ }
111
+ this.emitSpeechActivity(contextId, now);
112
+ }
113
+ else {
114
+ this.vadState = "stopping";
115
+ this.silenceFrames = 1;
116
+ this.stoppingSpikeFrames = 0;
117
+ }
118
+ break;
119
+ case "stopping":
120
+ if (isSpeech) {
121
+ // Single-frame confidence spikes are a known Silero failure mode on
122
+ // long telephony segments (state saturation flaps high through real
123
+ // silence). Require sustained speech to leave "stopping" so one
124
+ // spike cannot reset the silence countdown forever.
125
+ this.stoppingSpikeFrames += 1;
126
+ if (this.stoppingSpikeFrames >= 2) {
127
+ this.vadState = "speaking";
128
+ this.silenceFrames = 0;
129
+ this.stoppingSpikeFrames = 0;
130
+ this.emitSpeechActivity(contextId, now);
131
+ }
132
+ break;
133
+ }
134
+ this.stoppingSpikeFrames = 0;
135
+ this.silenceFrames += 1;
136
+ if (this.silenceFrames * 32 < this.tuning.minSilenceDurationMs + this.tuning.speechPadMs)
137
+ break;
138
+ if (this.silenceFrames < silenceFrameTarget)
139
+ break;
140
+ {
141
+ const hangoverMs = this.silenceFrames * 32;
142
+ this.vadState = "quiet";
143
+ this.silenceFrames = 0;
144
+ this.speechFrames = 0;
145
+ this.stoppingSpikeFrames = 0;
146
+ const ended = {
147
+ kind: "vad.speech_ended",
148
+ contextId,
149
+ timestampMs: now,
150
+ };
151
+ this.bus.push(Route.Main, ended);
152
+ this.bus.push(Route.Main, {
153
+ kind: "metric.conversation",
154
+ contextId,
155
+ timestampMs: now,
156
+ name: "vad.stop_hangover_ms",
157
+ value: String(hangoverMs),
158
+ });
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ promoteStartingToSpeakingIfReady(confidence, contextId, now) {
164
+ if (this.vadState !== "starting")
165
+ return false;
166
+ if (this.speechFrames * 32 < this.tuning.speechStartDurationMs)
167
+ return false;
168
+ const startDelayMs = this.speechFrames * 32;
169
+ this.vadState = "speaking";
170
+ this.speechFrames = 0;
171
+ const started = {
172
+ kind: "vad.speech_started",
173
+ contextId,
174
+ timestampMs: now,
175
+ confidence,
176
+ };
177
+ this.bus.push(Route.Main, started);
178
+ this.bus.push(Route.Main, {
179
+ kind: "metric.conversation",
180
+ contextId,
181
+ timestampMs: now,
182
+ name: "vad.start_delay_ms",
183
+ value: String(startDelayMs),
184
+ });
185
+ this.emitSpeechActivity(contextId, now);
186
+ return true;
187
+ }
188
+ emitSpeechActivity(contextId, now) {
189
+ const activity = {
190
+ kind: "vad.speech_activity",
191
+ contextId,
192
+ timestampMs: now,
193
+ isAsync: true,
194
+ };
195
+ this.bus.push(Route.Main, activity);
196
+ }
197
+ }
198
+ function readNumber(config, key, fallback) {
199
+ const value = config[key];
200
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
201
+ }
202
+ //# sourceMappingURL=vad-state-machine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vad-state-machine.js","sourceRoot":"","sources":["../src/vad-state-machine.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,+EAA+E;AAC/E,wEAAwE;AACxE,6EAA6E;AAC7E,6DAA6D;AAE7D,OAAO,EACL,KAAK,GAMN,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEjE,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AACtC,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAEtC,MAAM,6BAA6B,GAAG,IAAI,CAAC;AAY3C,MAAM,UAAU,aAAa,CAAC,MAAoB;IAChD,OAAO;QACL,mBAAmB,EAAE,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC;QACzD,oBAAoB,EAAE,UAAU,CAAC,MAAM,EAAE,yBAAyB,EAAE,GAAG,CAAC;QACxE,WAAW,EAAE,UAAU,CAAC,MAAM,EAAE,eAAe,EAAE,EAAE,CAAC;QACpD,qBAAqB,EAAE,UAAU,CAAC,MAAM,EAAE,0BAA0B,EAAE,EAAE,CAAC;QACzE,4BAA4B,EAAE,UAAU,CAAC,MAAM,EAAE,kCAAkC,EAAE,MAAM,CAAC;KAC7F,CAAC;AACJ,CAAC;AAED,sFAAsF;AACtF,MAAM,OAAO,iBAAiB;IACpB,cAAc,GAAa,EAAE,CAAC;IAEtC,IAAI,CAAC,KAAiB;QACpB,MAAM,OAAO,GAAG,mBAAmB,CAAC,KAAK,CAAC,CAAC;QAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC3C,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAE,GAAG,KAAK,CAAC,CAAC;QAChD,CAAC;IACH,CAAC;IAED,IAAI;QACF,IAAI,IAAI,CAAC,cAAc,CAAC,MAAM,GAAG,kBAAkB;YAAE,OAAO,IAAI,CAAC;QACjE,MAAM,MAAM,GAAG,IAAI,YAAY,CAAC,kBAAkB,CAAC,CAAC;QACpD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,kBAAkB,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,MAAM,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,EAAG,CAAC;QAC3C,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,KAAK;QACH,IAAI,CAAC,cAAc,GAAG,EAAE,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,OAAO,qBAAqB;IAQb;IACA;IAEA;IAVX,QAAQ,GAAa,OAAO,CAAC;IAC7B,YAAY,GAAG,CAAC,CAAC;IACjB,aAAa,GAAG,CAAC,CAAC;IAClB,mBAAmB,GAAG,CAAC,CAAC;IACxB,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEjC,YACmB,GAAgB,EAChB,MAAiB;IAClC,6EAA6E;IAC5D,YAAwB;QAHxB,QAAG,GAAH,GAAG,CAAa;QAChB,WAAM,GAAN,MAAM,CAAW;QAEjB,iBAAY,GAAZ,YAAY,CAAY;IACxC,CAAC;IAEJ,cAAc;QACZ,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAChC,CAAC;IAED,OAAO,CAAC,UAAkB,EAAE,SAAiB;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEvB,kEAAkE;QAClE,IAAI,IAAI,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,GAAG,IAAI,CAAC,WAAW,IAAI,6BAA6B,EAAE,CAAC;YACzF,IAAI,CAAC,YAAY,EAAE,CAAC;YACpB,IAAI,CAAC,WAAW,GAAG,GAAG,CAAC;QACzB,CAAC;QAED,MAAM,QAAQ,GAAG,UAAU,IAAI,IAAI,CAAC,MAAM,CAAC,mBAAmB,CAAC;QAC/D,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,oBAAoB,GAAG,EAAE,CAAC,CAAC,CAAC;QAEzF,QAAQ,IAAI,CAAC,QAAQ,EAAE,CAAC;YACtB,KAAK,OAAO;gBACV,IAAI,QAAQ,EAAE,CAAC;oBACb,IAAI,CAAC,QAAQ,GAAG,UAAU,CAAC;oBAC3B,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;oBACtB,IAAI,CAAC,gCAAgC,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC;gBACpE,CAAC;gBACD,MAAM;YAER,KAAK,UAAU;gBACb,IAAI,QAAQ,EAAE,CAAC;oBACb,IAAI,CAAC,IAAI,CAAC,gCAAgC,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,CAAC;wBACvE,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC;wBACvB,IAAI,CAAC,gCAAgC,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC;oBACpE,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;oBACxB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;gBACxB,CAAC;gBACD,MAAM;YAER,KAAK,UAAU;gBACb,IAAI,QAAQ,EAAE,CAAC;oBACb,6DAA6D;oBAC7D,qEAAqE;oBACrE,kEAAkE;oBAClE,+DAA+D;oBAC/D,sDAAsD;oBACtD,IAAI,GAAG,GAAG,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,MAAM,CAAC,4BAA4B,EAAE,CAAC;wBACvE,IAAI,CAAC,YAAY,EAAE,CAAC;wBACpB,IAAI,CAAC,WAAW,GAAG,GAAG,CAAC;wBACvB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;4BACxB,IAAI,EAAE,qBAAqB;4BAC3B,SAAS;4BACT,WAAW,EAAE,GAAG;4BAChB,IAAI,EAAE,2BAA2B;4BACjC,KAAK,EAAE,GAAG;yBACX,CAAC,CAAC;oBACL,CAAC;oBACD,IAAI,CAAC,kBAAkB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;gBAC1C,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,QAAQ,GAAG,UAAU,CAAC;oBAC3B,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;oBACvB,IAAI,CAAC,mBAAmB,GAAG,CAAC,CAAC;gBAC/B,CAAC;gBACD,MAAM;YAER,KAAK,UAAU;gBACb,IAAI,QAAQ,EAAE,CAAC;oBACb,oEAAoE;oBACpE,oEAAoE;oBACpE,gEAAgE;oBAChE,oDAAoD;oBACpD,IAAI,CAAC,mBAAmB,IAAI,CAAC,CAAC;oBAC9B,IAAI,IAAI,CAAC,mBAAmB,IAAI,CAAC,EAAE,CAAC;wBAClC,IAAI,CAAC,QAAQ,GAAG,UAAU,CAAC;wBAC3B,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;wBACvB,IAAI,CAAC,mBAAmB,GAAG,CAAC,CAAC;wBAC7B,IAAI,CAAC,kBAAkB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;oBAC1C,CAAC;oBACD,MAAM;gBACR,CAAC;gBACD,IAAI,CAAC,mBAAmB,GAAG,CAAC,CAAC;gBAC7B,IAAI,CAAC,aAAa,IAAI,CAAC,CAAC;gBACxB,IAAI,IAAI,CAAC,aAAa,GAAG,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,oBAAoB,GAAG,IAAI,CAAC,MAAM,CAAC,WAAW;oBAAE,MAAM;gBAChG,IAAI,IAAI,CAAC,aAAa,GAAG,kBAAkB;oBAAE,MAAM;gBAEnD,CAAC;oBACC,MAAM,UAAU,GAAG,IAAI,CAAC,aAAa,GAAG,EAAE,CAAC;oBAC3C,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;oBACxB,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;oBACvB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;oBACtB,IAAI,CAAC,mBAAmB,GAAG,CAAC,CAAC;oBAC7B,MAAM,KAAK,GAAyB;wBAClC,IAAI,EAAE,kBAAkB;wBACxB,SAAS;wBACT,WAAW,EAAE,GAAG;qBACjB,CAAC;oBACF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;oBACjC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;wBACxB,IAAI,EAAE,qBAAqB;wBAC3B,SAAS;wBACT,WAAW,EAAE,GAAG;wBAChB,IAAI,EAAE,sBAAsB;wBAC5B,KAAK,EAAE,MAAM,CAAC,UAAU,CAAC;qBAC1B,CAAC,CAAC;gBACL,CAAC;gBACD,MAAM;QACV,CAAC;IACH,CAAC;IAEO,gCAAgC,CACtC,UAAkB,EAClB,SAAiB,EACjB,GAAW;QAEX,IAAI,IAAI,CAAC,QAAQ,KAAK,UAAU;YAAE,OAAO,KAAK,CAAC;QAC/C,IAAI,IAAI,CAAC,YAAY,GAAG,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,qBAAqB;YAAE,OAAO,KAAK,CAAC;QAE7E,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QAC5C,IAAI,CAAC,QAAQ,GAAG,UAAU,CAAC;QAC3B,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QACtB,MAAM,OAAO,GAA2B;YACtC,IAAI,EAAE,oBAAoB;YAC1B,SAAS;YACT,WAAW,EAAE,GAAG;YAChB,UAAU;SACX,CAAC;QACF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACnC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;YACxB,IAAI,EAAE,qBAAqB;YAC3B,SAAS;YACT,WAAW,EAAE,GAAG;YAChB,IAAI,EAAE,oBAAoB;YAC1B,KAAK,EAAE,MAAM,CAAC,YAAY,CAAC;SAC5B,CAAC,CAAC;QACH,IAAI,CAAC,kBAAkB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QACxC,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,kBAAkB,CAAC,SAAiB,EAAE,GAAW;QACvD,MAAM,QAAQ,GAA4B;YACxC,IAAI,EAAE,qBAAqB;YAC3B,SAAS;YACT,WAAW,EAAE,GAAG;YAChB,OAAO,EAAE,IAAI;SACd,CAAC;QACF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IACtC,CAAC;CACF;AAED,SAAS,UAAU,CAAC,MAAoB,EAAE,GAAW,EAAE,QAAgB;IACrE,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;AAChF,CAAC"}
@@ -0,0 +1,20 @@
1
+ import type { PipelineBus } from "@kuralle-syrinx/core";
2
+ import { type PluginConfig, type VoicePlugin } from "@kuralle-syrinx/core";
3
+ export declare class SileroVADPlugin implements VoicePlugin {
4
+ private bus;
5
+ private session;
6
+ private ort;
7
+ private state;
8
+ private context;
9
+ private readonly windows;
10
+ private machine;
11
+ private sampleRate;
12
+ private disposers;
13
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
14
+ processAudio(audio: Uint8Array, contextId: string): Promise<void>;
15
+ close(): Promise<void>;
16
+ private runModel;
17
+ private resetModelState;
18
+ private emitError;
19
+ }
20
+ //# sourceMappingURL=workers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workers.d.ts","sourceRoot":"","sources":["../src/workers.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAGjB,MAAM,sBAAsB,CAAC;AAa9B,qBAAa,eAAgB,YAAW,WAAW;IACjD,OAAO,CAAC,GAAG,CAA4B;IACvC,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,GAAG,CAAoB;IAC/B,OAAO,CAAC,KAAK,CAAiC;IAC9C,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2B;IACnD,OAAO,CAAC,OAAO,CAAsC;IACrD,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAAyB;IAEpC,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBjE,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YASd,QAAQ;IA4BtB,OAAO,CAAC,eAAe;IAKvB,OAAO,CAAC,SAAS;CAYlB"}
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Silero VAD Plugin (Cloudflare Workers, onnxruntime-web)
4
+ //
5
+ // Same plugin as ./index.ts but for workerd: WASM execution provider and model
6
+ // bytes supplied via `model_bytes` or fetched from `model_url` (no filesystem).
7
+ // All turn/state decisions live in the shared SileroVadStateMachine
8
+ // (vad-state-machine.ts), so the Node and Workers variants can never drift.
9
+ import { ErrorCategory, Route, isRecoverable, optionalStringConfig, } from "@kuralle-syrinx/core";
10
+ import { CONTEXT_SAMPLES_16K, Pcm16WindowBuffer, SileroVadStateMachine, readVadTuning, } from "./vad-state-machine.js";
11
+ const DEFAULT_SAMPLE_RATE = 16000;
12
+ export class SileroVADPlugin {
13
+ bus = null;
14
+ session = null;
15
+ ort = null;
16
+ state = new Float32Array(2 * 1 * 128);
17
+ context = new Float32Array(CONTEXT_SAMPLES_16K);
18
+ windows = new Pcm16WindowBuffer();
19
+ machine = null;
20
+ sampleRate = DEFAULT_SAMPLE_RATE;
21
+ disposers = [];
22
+ async initialize(bus, config) {
23
+ this.bus = bus;
24
+ this.sampleRate = readSampleRate(config);
25
+ this.machine = new SileroVadStateMachine(bus, readVadTuning(config), () => this.resetModelState());
26
+ this.ort = await import("onnxruntime-web");
27
+ this.session = await this.ort.InferenceSession.create(await readModelBytes(config), {
28
+ executionProviders: ["wasm"],
29
+ });
30
+ this.resetModelState();
31
+ this.machine.noteModelReset();
32
+ this.disposers.push(bus.on("vad.audio", async (pkt) => {
33
+ const audioPkt = pkt;
34
+ await this.processAudio(audioPkt.audio, audioPkt.contextId);
35
+ }));
36
+ }
37
+ async processAudio(audio, contextId) {
38
+ if (!this.bus || !this.session || !this.ort || !this.machine)
39
+ return;
40
+ if (audio.byteLength % 2 !== 0) {
41
+ this.emitError(contextId, new Error("VAD audio must be 16-bit PCM with even byte length"));
42
+ return;
43
+ }
44
+ this.windows.push(audio);
45
+ for (let window = this.windows.next(); window; window = this.windows.next()) {
46
+ const confidence = await this.runModel(window, contextId);
47
+ this.machine.observe(confidence, contextId);
48
+ }
49
+ }
50
+ async close() {
51
+ for (const dispose of this.disposers.splice(0))
52
+ dispose();
53
+ this.bus = null;
54
+ this.session = null;
55
+ this.ort = null;
56
+ this.windows.clear();
57
+ this.resetModelState();
58
+ }
59
+ async runModel(window, contextId) {
60
+ if (!this.session || !this.ort)
61
+ return 0;
62
+ const input = new Float32Array(CONTEXT_SAMPLES_16K + window.length);
63
+ input.set(this.context, 0);
64
+ input.set(window, CONTEXT_SAMPLES_16K);
65
+ try {
66
+ const output = await this.session.run({
67
+ input: new this.ort.Tensor("float32", input, [1, input.length]),
68
+ state: new this.ort.Tensor("float32", this.state, [2, 1, 128]),
69
+ sr: new this.ort.Tensor("int64", BigInt64Array.from([BigInt(this.sampleRate)]), []),
70
+ });
71
+ const probability = output["output"]?.data?.[0];
72
+ const nextState = output["stateN"]?.data;
73
+ if (nextState instanceof Float32Array) {
74
+ this.state = new Float32Array(nextState);
75
+ }
76
+ this.context = input.slice(-CONTEXT_SAMPLES_16K);
77
+ return typeof probability === "number" ? probability : 0;
78
+ }
79
+ catch (err) {
80
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
81
+ return 0;
82
+ }
83
+ }
84
+ resetModelState() {
85
+ this.state = new Float32Array(2 * 1 * 128);
86
+ this.context = new Float32Array(CONTEXT_SAMPLES_16K);
87
+ }
88
+ emitError(contextId, err) {
89
+ const packet = {
90
+ kind: "vad.error",
91
+ contextId,
92
+ timestampMs: Date.now(),
93
+ component: "vad",
94
+ category: ErrorCategory.InvalidInput,
95
+ cause: err,
96
+ isRecoverable: isRecoverable(ErrorCategory.InvalidInput),
97
+ };
98
+ this.bus?.push(Route.Critical, packet);
99
+ }
100
+ }
101
+ async function readModelBytes(config) {
102
+ const bytes = config["model_bytes"];
103
+ if (bytes instanceof Uint8Array)
104
+ return bytes;
105
+ if (bytes instanceof ArrayBuffer)
106
+ return new Uint8Array(bytes);
107
+ const modelUrl = optionalStringConfig(config, "model_url");
108
+ if (!modelUrl) {
109
+ throw new Error("SileroVADPlugin on Workers requires model_bytes or model_url");
110
+ }
111
+ const response = await fetch(modelUrl);
112
+ if (!response.ok)
113
+ throw new Error(`Failed to fetch Silero model: ${String(response.status)}`);
114
+ return new Uint8Array(await response.arrayBuffer());
115
+ }
116
+ function readSampleRate(config) {
117
+ const value = config["sample_rate"];
118
+ const sampleRate = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_SAMPLE_RATE;
119
+ if (sampleRate !== 16000) {
120
+ throw new Error(`SileroVADPlugin requires 16 kHz PCM input, got ${String(sampleRate)} Hz`);
121
+ }
122
+ return sampleRate;
123
+ }
124
+ //# sourceMappingURL=workers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workers.js","sourceRoot":"","sources":["../src/workers.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/silero-vad",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "Silero VAD plugin for Syrinx — ONNX voice activity detection for Node and Workers",
6
6
  "keywords": [
@@ -23,23 +23,36 @@
23
23
  "url": "https://github.com/kuralle/syrinx/issues"
24
24
  },
25
25
  "type": "module",
26
- "main": "./src/index.ts",
27
- "types": "./src/index.ts",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
28
  "exports": {
29
- ".": "./src/index.ts",
30
- "./workers": "./src/workers.ts"
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./workers": {
34
+ "types": "./dist/workers.d.ts",
35
+ "default": "./dist/workers.js"
36
+ }
31
37
  },
32
38
  "dependencies": {
33
39
  "onnxruntime-node": "1.24.3",
34
40
  "onnxruntime-web": "1.24.3",
35
- "@kuralle-syrinx/core": "4.4.1"
41
+ "@kuralle-syrinx/core": "4.5.0"
36
42
  },
37
43
  "devDependencies": {
44
+ "@types/node": "^22.0.0",
38
45
  "typescript": "^5.7.0",
39
46
  "vitest": "^3.2.6"
40
47
  },
48
+ "files": [
49
+ "dist",
50
+ "src",
51
+ "README.md"
52
+ ],
41
53
  "scripts": {
42
54
  "typecheck": "tsc --noEmit",
43
- "test": "vitest run"
55
+ "test": "vitest run",
56
+ "build": "tsc -p tsconfig.build.json"
44
57
  }
45
58
  }
@@ -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
+ });
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { PipelineBus } from "@kuralle-syrinx/core";
5
+
6
+ import { SileroVADPlugin } from "./workers.js";
7
+
8
+ const mockControl = vi.hoisted(() => ({
9
+ confidence: 0.9,
10
+ stateCallArgs: [] as Float32Array[],
11
+ }));
12
+
13
+ vi.mock("onnxruntime-web", () => {
14
+ function MockTensor(this: { data: unknown }, _type: string, data: unknown, _shape: unknown) {
15
+ this.data = data;
16
+ }
17
+ const run = vi.fn(async (inputs: Record<string, { data: unknown }>) => {
18
+ const state = inputs["state"]?.data;
19
+ if (state instanceof Float32Array) {
20
+ mockControl.stateCallArgs.push(new Float32Array(state));
21
+ }
22
+ return {
23
+ output: { data: [mockControl.confidence] },
24
+ stateN: { data: new Float32Array(2 * 1 * 128).fill(0.5) },
25
+ };
26
+ });
27
+ return {
28
+ InferenceSession: { create: vi.fn(async () => ({ run })) },
29
+ Tensor: MockTensor,
30
+ };
31
+ });
32
+
33
+ function makeBus(): { bus: PipelineBus; emitted: Array<{ kind: string }> } {
34
+ const emitted: Array<{ kind: string }> = [];
35
+ const bus = {
36
+ push(_route: unknown, pkt: unknown) {
37
+ emitted.push(pkt as { kind: string });
38
+ },
39
+ on(_event: string, _handler: unknown) {
40
+ return () => {};
41
+ },
42
+ } as unknown as PipelineBus;
43
+ return { bus, emitted };
44
+ }
45
+
46
+ function makePcmFrame(): Uint8Array {
47
+ return new Uint8Array(512 * 2);
48
+ }
49
+
50
+ async function initPlugin(bus: PipelineBus, config: Record<string, unknown> = {}): Promise<SileroVADPlugin> {
51
+ const plugin = new SileroVADPlugin();
52
+ await plugin.initialize(bus, { model_bytes: new Uint8Array(8), ...config });
53
+ return plugin;
54
+ }
55
+
56
+ function countKind(emitted: Array<{ kind: string }>, kind: string): number {
57
+ return emitted.filter((p) => p.kind === kind).length;
58
+ }
59
+
60
+ function metricValue(
61
+ emitted: Array<{ kind: string; name?: string; value?: string }>,
62
+ name: string,
63
+ ): string | undefined {
64
+ return emitted.find((p) => p.kind === "metric.conversation" && p.name === name)?.value;
65
+ }
66
+
67
+ describe("SileroVADPlugin (Workers) — shared state machine parity", () => {
68
+ beforeEach(() => {
69
+ vi.useFakeTimers();
70
+ vi.setSystemTime(0);
71
+ mockControl.confidence = 0.9;
72
+ mockControl.stateCallArgs.length = 0;
73
+ });
74
+
75
+ afterEach(() => {
76
+ vi.useRealTimers();
77
+ });
78
+
79
+ async function frame(plugin: SileroVADPlugin, confidence: number): Promise<void> {
80
+ mockControl.confidence = confidence;
81
+ await plugin.processAudio(makePcmFrame(), "ctx-w");
82
+ }
83
+
84
+ it("absorbs single-frame confidence spikes during stopping so speech still ends", async () => {
85
+ const { bus, emitted } = makeBus();
86
+ const plugin = await initPlugin(bus);
87
+
88
+ await frame(plugin, 0.9);
89
+ expect(countKind(emitted, "vad.speech_started")).toBe(1);
90
+
91
+ for (const confidence of [0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2]) {
92
+ await frame(plugin, confidence);
93
+ }
94
+
95
+ expect(countKind(emitted, "vad.speech_ended")).toBe(1);
96
+ expect(metricValue(emitted, "vad.stop_hangover_ms")).toBeDefined();
97
+ });
98
+
99
+ it("resets model state during prolonged continuous speech (saturation guard)", async () => {
100
+ const { bus, emitted } = makeBus();
101
+ const plugin = await initPlugin(bus);
102
+
103
+ await frame(plugin, 0.9);
104
+ vi.setSystemTime(13_000);
105
+ await frame(plugin, 0.9);
106
+ await frame(plugin, 0.9);
107
+
108
+ expect(metricValue(emitted, "vad.state_reset_in_speech")).toBe("1");
109
+ expect(mockControl.stateCallArgs[2]!.every((value) => value === 0)).toBe(true);
110
+ });
111
+
112
+ it("two consecutive speech frames during stopping resume speaking", async () => {
113
+ const { bus, emitted } = makeBus();
114
+ const plugin = await initPlugin(bus);
115
+
116
+ await frame(plugin, 0.9);
117
+ for (const confidence of [0.2, 0.2, 0.2]) await frame(plugin, confidence);
118
+ for (const confidence of [0.9, 0.9]) await frame(plugin, confidence);
119
+ expect(countKind(emitted, "vad.speech_ended")).toBe(0);
120
+
121
+ for (let i = 0; i < 9; i += 1) await frame(plugin, 0.2);
122
+ expect(countKind(emitted, "vad.speech_ended")).toBe(1);
123
+ });
124
+ });
Binary file