@crewhaus/vad-engine 0.1.4 → 0.1.5

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,50 @@
1
+ /**
2
+ * Catalog R16 `vad-engine` — Section 24 VOICE.
3
+ *
4
+ * Voice activity detection. WebRTC-VAD-style heuristic over PCM 16-bit
5
+ * frames: a frame is "speech" when BOTH (a) RMS energy crosses an
6
+ * aggressiveness-tuned threshold and (b) zero-crossing-rate sits in a
7
+ * speech-shaped band. Either signal alone produces a "transitioning"
8
+ * verdict, used by `barge-in-controller` to apply hysteresis (don't
9
+ * bargle in on a 50ms cough).
10
+ *
11
+ * Frame size is conventional: 30ms at the input sample rate. At 24kHz
12
+ * mono that's 720 samples; the detector accepts ANY length but reports
13
+ * the verdict as if it were a single frame, so callers framing at
14
+ * higher granularity will simply get richer output.
15
+ *
16
+ * Aggressiveness:
17
+ * 0 — most permissive (high speech recall, low false positives)
18
+ * 1 — default
19
+ * 2 — moderately aggressive (ignores low-amplitude background)
20
+ * 3 — most aggressive (rejects most non-vocal audio)
21
+ *
22
+ * No external ML deps: pure heuristic. Adequate for barge-in and as a
23
+ * pre-filter for paid VAD models. `T9` random-noise stability test
24
+ * proves a 10-second white-noise stream produces zero "speech" verdicts
25
+ * at aggressiveness 2+.
26
+ */
27
+ export type Aggressiveness = 0 | 1 | 2 | 3;
28
+ export type VadVerdict = "speech" | "silence" | "transitioning";
29
+ export type DetectorOptions = {
30
+ readonly aggressiveness?: Aggressiveness;
31
+ /**
32
+ * Sample rate of incoming PCM (Hz). RESERVED — currently has no effect on
33
+ * detection: the energy thresholds and zero-crossing band below are
34
+ * sample-rate-independent, and the detector does NOT resample. The option is
35
+ * accepted for forward compatibility (a planned band-pass filter stage). Pass
36
+ * it for clarity if you like, but do not infer any resampling behaviour from
37
+ * it.
38
+ */
39
+ readonly sampleRate?: number;
40
+ };
41
+ export interface VadDetector {
42
+ detect(frame: Int16Array): VadVerdict;
43
+ readonly aggressiveness: Aggressiveness;
44
+ }
45
+ export declare function createVadDetector(opts?: DetectorOptions): VadDetector;
46
+ /**
47
+ * Helper: split a long PCM buffer into 30ms frames at `sampleRate`. Tail
48
+ * shorter than one frame is dropped (caller can decide to pad).
49
+ */
50
+ export declare function frames30ms(pcm: Int16Array, sampleRate?: number): Int16Array[];
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Catalog R16 `vad-engine` — Section 24 VOICE.
3
+ *
4
+ * Voice activity detection. WebRTC-VAD-style heuristic over PCM 16-bit
5
+ * frames: a frame is "speech" when BOTH (a) RMS energy crosses an
6
+ * aggressiveness-tuned threshold and (b) zero-crossing-rate sits in a
7
+ * speech-shaped band. Either signal alone produces a "transitioning"
8
+ * verdict, used by `barge-in-controller` to apply hysteresis (don't
9
+ * bargle in on a 50ms cough).
10
+ *
11
+ * Frame size is conventional: 30ms at the input sample rate. At 24kHz
12
+ * mono that's 720 samples; the detector accepts ANY length but reports
13
+ * the verdict as if it were a single frame, so callers framing at
14
+ * higher granularity will simply get richer output.
15
+ *
16
+ * Aggressiveness:
17
+ * 0 — most permissive (high speech recall, low false positives)
18
+ * 1 — default
19
+ * 2 — moderately aggressive (ignores low-amplitude background)
20
+ * 3 — most aggressive (rejects most non-vocal audio)
21
+ *
22
+ * No external ML deps: pure heuristic. Adequate for barge-in and as a
23
+ * pre-filter for paid VAD models. `T9` random-noise stability test
24
+ * proves a 10-second white-noise stream produces zero "speech" verdicts
25
+ * at aggressiveness 2+.
26
+ */
27
+ const ENERGY_THRESHOLDS = {
28
+ 0: 280,
29
+ 1: 540,
30
+ 2: 900,
31
+ 3: 1500,
32
+ };
33
+ // Speech-shaped ZCR band (per 1k samples). Empirical for 24kHz speech
34
+ // vs ambient noise vs single-frequency tones. The lower bound rejects
35
+ // pure low-frequency tones (e.g. 60Hz mains hum); the upper bound
36
+ // rejects white noise / high-frequency hiss.
37
+ const ZCR_LOW_PER_1K = 15;
38
+ const ZCR_HIGH_PER_1K = 220;
39
+ export function createVadDetector(opts = {}) {
40
+ const aggressiveness = opts.aggressiveness ?? 1;
41
+ void opts.sampleRate; // currently unused; reserved for future bandpass filter
42
+ return {
43
+ aggressiveness,
44
+ detect(frame) {
45
+ if (frame.length === 0)
46
+ return "silence";
47
+ let sumSq = 0;
48
+ let zeroCrossings = 0;
49
+ let prev = frame[0] ?? 0;
50
+ for (let i = 0; i < frame.length; i++) {
51
+ const v = frame[i] ?? 0;
52
+ sumSq += v * v;
53
+ if (i > 0 && Math.sign(v) !== Math.sign(prev) && (v !== 0 || prev !== 0)) {
54
+ zeroCrossings += 1;
55
+ }
56
+ prev = v;
57
+ }
58
+ const rms = Math.sqrt(sumSq / frame.length);
59
+ const zcrPer1k = (zeroCrossings * 1000) / frame.length;
60
+ const energyOk = rms >= ENERGY_THRESHOLDS[aggressiveness];
61
+ const zcrInBand = zcrPer1k >= ZCR_LOW_PER_1K && zcrPer1k <= ZCR_HIGH_PER_1K;
62
+ if (energyOk && zcrInBand)
63
+ return "speech";
64
+ if (!energyOk && !zcrInBand)
65
+ return "silence";
66
+ return "transitioning";
67
+ },
68
+ };
69
+ }
70
+ /**
71
+ * Helper: split a long PCM buffer into 30ms frames at `sampleRate`. Tail
72
+ * shorter than one frame is dropped (caller can decide to pad).
73
+ */
74
+ export function frames30ms(pcm, sampleRate = 24_000) {
75
+ const n = Math.floor(sampleRate * 0.03);
76
+ const out = [];
77
+ for (let i = 0; i + n <= pcm.length; i += n) {
78
+ out.push(pcm.subarray(i, i + n));
79
+ }
80
+ return out;
81
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@crewhaus/vad-engine",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Voice activity detection — energy + ZCR heuristic over PCM 16-bit frames (Section 24 VOICE)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
@@ -29,5 +32,5 @@
29
32
  "publishConfig": {
30
33
  "access": "public"
31
34
  },
32
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
35
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
33
36
  }
package/src/index.test.ts DELETED
@@ -1,124 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createVadDetector, frames30ms } from "./index.js";
3
-
4
- function silence(n: number): Int16Array {
5
- return new Int16Array(n);
6
- }
7
-
8
- function whiteNoise(n: number, amplitude: number, seed = 1): Int16Array {
9
- // Linear-congruential PRNG so tests are deterministic.
10
- let s = seed;
11
- const out = new Int16Array(n);
12
- for (let i = 0; i < n; i++) {
13
- s = (s * 1103515245 + 12345) & 0x7fffffff;
14
- const r = ((s % 1000) - 500) / 500; // [-1, 1)
15
- out[i] = Math.round(r * amplitude);
16
- }
17
- return out;
18
- }
19
-
20
- function tone(n: number, freqHz: number, sampleRate: number, amplitude: number): Int16Array {
21
- const out = new Int16Array(n);
22
- for (let i = 0; i < n; i++) {
23
- out[i] = Math.round(Math.sin((2 * Math.PI * freqHz * i) / sampleRate) * amplitude);
24
- }
25
- return out;
26
- }
27
-
28
- /**
29
- * Speech-like signal: a sum of two formants (F1 ≈ 500Hz, F2 ≈ 1500Hz)
30
- * with amplitude modulation to approximate voicing. Enough to push both
31
- * energy and ZCR into the speech band for aggressiveness ≤ 2.
32
- */
33
- function speechLike(n: number, sampleRate: number, amplitude: number): Int16Array {
34
- const out = new Int16Array(n);
35
- for (let i = 0; i < n; i++) {
36
- const env = 0.6 + 0.4 * Math.sin((2 * Math.PI * 5 * i) / sampleRate);
37
- const sig =
38
- 0.7 * Math.sin((2 * Math.PI * 500 * i) / sampleRate) +
39
- 0.3 * Math.sin((2 * Math.PI * 1500 * i) / sampleRate);
40
- out[i] = Math.round(env * sig * amplitude);
41
- }
42
- return out;
43
- }
44
-
45
- describe("createVadDetector — energy + ZCR heuristic (T1)", () => {
46
- test("silent frame → silence at every aggressiveness", () => {
47
- for (const a of [0, 1, 2, 3] as const) {
48
- const det = createVadDetector({ aggressiveness: a });
49
- expect(det.detect(silence(720))).toBe("silence");
50
- }
51
- });
52
-
53
- test("speech-like signal → speech at aggressiveness 0..2", () => {
54
- const f = speechLike(720, 24_000, 8000);
55
- for (const a of [0, 1, 2] as const) {
56
- const det = createVadDetector({ aggressiveness: a });
57
- expect(det.detect(f)).toBe("speech");
58
- }
59
- });
60
-
61
- test("aggressiveness=3 ignores low-amplitude speech-shaped signal", () => {
62
- const det = createVadDetector({ aggressiveness: 3 });
63
- const f = speechLike(720, 24_000, 1500);
64
- // Low energy at aggressiveness 3: should NOT be classified as speech.
65
- expect(det.detect(f)).not.toBe("speech");
66
- });
67
-
68
- test("low-energy white noise → silence (T9 noise rejection at aggressiveness ≥ 2)", () => {
69
- const det = createVadDetector({ aggressiveness: 2 });
70
- const f = whiteNoise(720, 250);
71
- // Low-amplitude noise → energy below threshold.
72
- expect(det.detect(f)).toBe("silence");
73
- });
74
-
75
- test("loud white noise → not speech (ZCR too high)", () => {
76
- // White noise has ZCR much higher than the speech band.
77
- const det = createVadDetector({ aggressiveness: 1 });
78
- const f = whiteNoise(720, 12000);
79
- // High energy but ZCR way out of band → transitioning, NOT speech.
80
- expect(det.detect(f)).not.toBe("speech");
81
- });
82
-
83
- test("pure tone at 60Hz → not speech (ZCR too low)", () => {
84
- const det = createVadDetector({ aggressiveness: 1 });
85
- const f = tone(720, 60, 24_000, 8000);
86
- // Energy passes but ZCR is too low → not speech.
87
- expect(det.detect(f)).not.toBe("speech");
88
- });
89
-
90
- test("empty frame → silence", () => {
91
- const det = createVadDetector({ aggressiveness: 1 });
92
- expect(det.detect(new Int16Array(0))).toBe("silence");
93
- });
94
- });
95
-
96
- describe("frames30ms helper", () => {
97
- test("splits a 2-second PCM buffer into 66 30ms frames at 24kHz", () => {
98
- const pcm = new Int16Array(24_000 * 2);
99
- const fs = frames30ms(pcm, 24_000);
100
- expect(fs.length).toBe(66); // floor(48000 / 720) = 66
101
- expect(fs[0]?.length).toBe(720);
102
- });
103
-
104
- test("drops a partial tail shorter than one frame", () => {
105
- const pcm = new Int16Array(720 + 100);
106
- const fs = frames30ms(pcm, 24_000);
107
- expect(fs.length).toBe(1);
108
- });
109
- });
110
-
111
- describe("T9 noise-injection stability — random noise at aggressiveness 2+ never reports speech", () => {
112
- test("10-second white-noise stream → 0 speech verdicts at aggressiveness 2", () => {
113
- const sr = 24_000;
114
- const det = createVadDetector({ aggressiveness: 2 });
115
- const pcm = whiteNoise(sr * 10, 6000, 42);
116
- const fs = frames30ms(pcm, sr);
117
- const counts = { speech: 0, silence: 0, transitioning: 0 };
118
- for (const f of fs) {
119
- counts[det.detect(f)] += 1;
120
- }
121
- expect(counts.speech).toBe(0);
122
- expect(counts.silence + counts.transitioning).toBe(fs.length);
123
- });
124
- });
package/src/index.ts DELETED
@@ -1,107 +0,0 @@
1
- /**
2
- * Catalog R16 `vad-engine` — Section 24 VOICE.
3
- *
4
- * Voice activity detection. WebRTC-VAD-style heuristic over PCM 16-bit
5
- * frames: a frame is "speech" when BOTH (a) RMS energy crosses an
6
- * aggressiveness-tuned threshold and (b) zero-crossing-rate sits in a
7
- * speech-shaped band. Either signal alone produces a "transitioning"
8
- * verdict, used by `barge-in-controller` to apply hysteresis (don't
9
- * bargle in on a 50ms cough).
10
- *
11
- * Frame size is conventional: 30ms at the input sample rate. At 24kHz
12
- * mono that's 720 samples; the detector accepts ANY length but reports
13
- * the verdict as if it were a single frame, so callers framing at
14
- * higher granularity will simply get richer output.
15
- *
16
- * Aggressiveness:
17
- * 0 — most permissive (high speech recall, low false positives)
18
- * 1 — default
19
- * 2 — moderately aggressive (ignores low-amplitude background)
20
- * 3 — most aggressive (rejects most non-vocal audio)
21
- *
22
- * No external ML deps: pure heuristic. Adequate for barge-in and as a
23
- * pre-filter for paid VAD models. `T9` random-noise stability test
24
- * proves a 10-second white-noise stream produces zero "speech" verdicts
25
- * at aggressiveness 2+.
26
- */
27
-
28
- export type Aggressiveness = 0 | 1 | 2 | 3;
29
-
30
- export type VadVerdict = "speech" | "silence" | "transitioning";
31
-
32
- export type DetectorOptions = {
33
- readonly aggressiveness?: Aggressiveness;
34
- /**
35
- * Sample rate of incoming PCM (Hz). RESERVED — currently has no effect on
36
- * detection: the energy thresholds and zero-crossing band below are
37
- * sample-rate-independent, and the detector does NOT resample. The option is
38
- * accepted for forward compatibility (a planned band-pass filter stage). Pass
39
- * it for clarity if you like, but do not infer any resampling behaviour from
40
- * it.
41
- */
42
- readonly sampleRate?: number;
43
- };
44
-
45
- const ENERGY_THRESHOLDS: Record<Aggressiveness, number> = {
46
- 0: 280,
47
- 1: 540,
48
- 2: 900,
49
- 3: 1500,
50
- };
51
-
52
- // Speech-shaped ZCR band (per 1k samples). Empirical for 24kHz speech
53
- // vs ambient noise vs single-frequency tones. The lower bound rejects
54
- // pure low-frequency tones (e.g. 60Hz mains hum); the upper bound
55
- // rejects white noise / high-frequency hiss.
56
- const ZCR_LOW_PER_1K = 15;
57
- const ZCR_HIGH_PER_1K = 220;
58
-
59
- export interface VadDetector {
60
- detect(frame: Int16Array): VadVerdict;
61
- readonly aggressiveness: Aggressiveness;
62
- }
63
-
64
- export function createVadDetector(opts: DetectorOptions = {}): VadDetector {
65
- const aggressiveness = opts.aggressiveness ?? 1;
66
- void opts.sampleRate; // currently unused; reserved for future bandpass filter
67
-
68
- return {
69
- aggressiveness,
70
- detect(frame: Int16Array): VadVerdict {
71
- if (frame.length === 0) return "silence";
72
- let sumSq = 0;
73
- let zeroCrossings = 0;
74
- let prev = frame[0] ?? 0;
75
- for (let i = 0; i < frame.length; i++) {
76
- const v = frame[i] ?? 0;
77
- sumSq += v * v;
78
- if (i > 0 && Math.sign(v) !== Math.sign(prev) && (v !== 0 || prev !== 0)) {
79
- zeroCrossings += 1;
80
- }
81
- prev = v;
82
- }
83
- const rms = Math.sqrt(sumSq / frame.length);
84
- const zcrPer1k = (zeroCrossings * 1000) / frame.length;
85
-
86
- const energyOk = rms >= ENERGY_THRESHOLDS[aggressiveness];
87
- const zcrInBand = zcrPer1k >= ZCR_LOW_PER_1K && zcrPer1k <= ZCR_HIGH_PER_1K;
88
-
89
- if (energyOk && zcrInBand) return "speech";
90
- if (!energyOk && !zcrInBand) return "silence";
91
- return "transitioning";
92
- },
93
- };
94
- }
95
-
96
- /**
97
- * Helper: split a long PCM buffer into 30ms frames at `sampleRate`. Tail
98
- * shorter than one frame is dropped (caller can decide to pad).
99
- */
100
- export function frames30ms(pcm: Int16Array, sampleRate = 24_000): Int16Array[] {
101
- const n = Math.floor(sampleRate * 0.03);
102
- const out: Int16Array[] = [];
103
- for (let i = 0; i + n <= pcm.length; i += n) {
104
- out.push(pcm.subarray(i, i + n));
105
- }
106
- return out;
107
- }