mlx-bun 0.4.0 → 0.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.
Files changed (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.4.0",
4
- "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
3
+ "version": "0.5.0",
4
+ "description": "Native MLX inference for Bun on Apple Silicon \u2014 local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -0,0 +1,112 @@
1
+ // In-process audio decoding through macOS AudioToolbox (ExtAudioFile) over
2
+ // bun:ffi — every container/codec CoreAudio reads (mp3, m4a/aac, flac, ogg,
3
+ // aiff, caf, wav, mov/mp4 audio tracks…) straight to 16 kHz mono float32,
4
+ // with CoreAudio's own resampler. Replaces the `afconvert` subprocess of
5
+ // transcode.ts (no process spawn, no WAV re-parse); the bytes still touch a
6
+ // temp file because ExtAudioFile opens URLs.
7
+
8
+ import { dlopen, FFIType, ptr, read } from "bun:ffi";
9
+ import { randomUUID } from "node:crypto";
10
+ import { unlinkSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const { ptr: P, i32, u32, u64, i64 } = FFIType;
15
+
16
+ function openLibs() {
17
+ const at = dlopen("/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox", {
18
+ ExtAudioFileOpenURL: { args: [u64, P], returns: i32 },
19
+ ExtAudioFileSetProperty: { args: [u64, u32, u32, P], returns: i32 },
20
+ ExtAudioFileGetProperty: { args: [u64, u32, P, P], returns: i32 },
21
+ ExtAudioFileRead: { args: [u64, P, P], returns: i32 },
22
+ ExtAudioFileDispose: { args: [u64], returns: i32 },
23
+ }).symbols;
24
+ const cf = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", {
25
+ CFURLCreateFromFileSystemRepresentation: { args: [u64, P, i64, FFIType.bool], returns: u64 },
26
+ CFRelease: { args: [u64], returns: i32 },
27
+ }).symbols;
28
+ return { at, cf };
29
+ }
30
+
31
+ let libs: ReturnType<typeof openLibs> | null = null;
32
+ const load = () => (libs ??= openLibs());
33
+
34
+ const fourcc = (s: string) => (s.charCodeAt(0) << 24 | s.charCodeAt(1) << 16 | s.charCodeAt(2) << 8 | s.charCodeAt(3)) >>> 0;
35
+ const kExtAudioFileProperty_ClientDataFormat = fourcc("cfmt");
36
+ const kExtAudioFileProperty_FileLengthFrames = fourcc("#frm");
37
+ const kAudioFormatLinearPCM = fourcc("lpcm");
38
+ const kAudioFormatFlagIsFloat = 1;
39
+ const kAudioFormatFlagIsPacked = 8;
40
+
41
+ export const AUDIOTOOLBOX_SAMPLE_RATE = 16_000;
42
+
43
+ /** Decode a file to 16 kHz mono float32 with CoreAudio. Throws on undecodable input. */
44
+ export function decodeFileWithAudioToolbox(path: string, sampleRate = AUDIOTOOLBOX_SAMPLE_RATE): Float32Array {
45
+ const { at, cf } = load();
46
+ const pathBuf = Buffer.from(path, "utf8");
47
+ const url = cf.CFURLCreateFromFileSystemRepresentation(0n, ptr(pathBuf), BigInt(pathBuf.length), false) as bigint;
48
+ if (!url) throw new Error("AudioToolbox: cannot build file URL");
49
+ const refSlot = new BigUint64Array(1);
50
+ try {
51
+ let st = at.ExtAudioFileOpenURL(url, ptr(refSlot)) as number;
52
+ if (st !== 0) throw new Error(`AudioToolbox: unrecognized or undecodable audio (ExtAudioFileOpenURL ${st})`);
53
+ const ref = read.u64(ptr(refSlot), 0);
54
+ try {
55
+ // AudioStreamBasicDescription: 40 bytes
56
+ const asbd = Buffer.alloc(40);
57
+ asbd.writeDoubleLE(sampleRate, 0);
58
+ asbd.writeUInt32LE(kAudioFormatLinearPCM, 8);
59
+ asbd.writeUInt32LE(kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked, 12);
60
+ asbd.writeUInt32LE(4, 16); // bytes per packet
61
+ asbd.writeUInt32LE(1, 20); // frames per packet
62
+ asbd.writeUInt32LE(4, 24); // bytes per frame
63
+ asbd.writeUInt32LE(1, 28); // channels
64
+ asbd.writeUInt32LE(32, 32); // bits per channel
65
+ st = at.ExtAudioFileSetProperty(ref, kExtAudioFileProperty_ClientDataFormat, 40, ptr(asbd)) as number;
66
+ if (st !== 0) throw new Error(`AudioToolbox: cannot set client format (${st})`);
67
+ // frame count hint (source rate), used only to size the first buffer
68
+ const lenSlot = new BigInt64Array(1);
69
+ const lenSize = new Uint32Array([8]);
70
+ at.ExtAudioFileGetProperty(ref, kExtAudioFileProperty_FileLengthFrames, ptr(lenSize), ptr(lenSlot));
71
+ const chunkFrames = 1 << 16;
72
+ const chunk = new Float32Array(chunkFrames);
73
+ // AudioBufferList { UInt32 mNumberBuffers; [pad 4]; AudioBuffer { UInt32 mNumberChannels; UInt32 mDataByteSize; void* mData } }
74
+ const abl = Buffer.alloc(24);
75
+ const frames = new Uint32Array(1);
76
+ const parts: Float32Array[] = [];
77
+ let total = 0;
78
+ for (;;) {
79
+ abl.writeUInt32LE(1, 0);
80
+ abl.writeUInt32LE(1, 8);
81
+ abl.writeUInt32LE(chunkFrames * 4, 12);
82
+ abl.writeBigUInt64LE(BigInt(ptr(chunk)), 16);
83
+ frames[0] = chunkFrames;
84
+ st = at.ExtAudioFileRead(ref, ptr(frames), ptr(abl)) as number;
85
+ if (st !== 0) throw new Error(`AudioToolbox: decode failed (ExtAudioFileRead ${st})`);
86
+ const n = frames[0]!;
87
+ if (n === 0) break;
88
+ parts.push(chunk.slice(0, n));
89
+ total += n;
90
+ }
91
+ const out = new Float32Array(total);
92
+ let o = 0;
93
+ for (const p of parts) { out.set(p, o); o += p.length; }
94
+ return out;
95
+ } finally {
96
+ at.ExtAudioFileDispose(ref);
97
+ }
98
+ } finally {
99
+ cf.CFRelease(url);
100
+ }
101
+ }
102
+
103
+ /** Decode in-memory bytes (any CoreAudio-readable container) via a temp file. */
104
+ export function decodeBytesWithAudioToolbox(bytes: Uint8Array, sampleRate = AUDIOTOOLBOX_SAMPLE_RATE): Float32Array {
105
+ const path = join(tmpdir(), `mlx-bun-audio-${randomUUID()}`);
106
+ writeFileSync(path, bytes);
107
+ try {
108
+ return decodeFileWithAudioToolbox(path, sampleRate);
109
+ } finally {
110
+ try { unlinkSync(path); } catch { /* already gone */ }
111
+ }
112
+ }
@@ -43,6 +43,7 @@
43
43
  // mlx inline-temporary hazard (CLAUDE.md): every op result is held in a
44
44
  // local and disposed; no nested `ops.foo(x.slice(...))` chains.
45
45
 
46
+ import { tensorFingerprint } from "../model/fingerprint";
46
47
  import { ptr, read } from "bun:ffi";
47
48
  import { MlxArray, cpuStream } from "../mlx/array";
48
49
  import { C, Dtype } from "../mlx/ffi";
@@ -115,6 +116,13 @@ interface AttnContext {
115
116
 
116
117
  export class AudioTower {
117
118
  #weights = new Map<string, MlxArray>();
119
+ #cacheIdentity?: string;
120
+ /** Immutable encoder weights and computation settings, hashed once on use. */
121
+ get cacheIdentity(): string {
122
+ return this.#cacheIdentity ??= new Bun.CryptoHasher("sha256")
123
+ .update("gemma-audio-raw-v1").update(JSON.stringify([this.cfg, this.embedScale]))
124
+ .update(tensorFingerprint(this.#weights)).digest("hex");
125
+ }
118
126
  readonly cfg: AudioTowerConfig;
119
127
  readonly embedScale: number;
120
128
 
@@ -0,0 +1,98 @@
1
+ // Microphone capture for `mlx-bun dictate`: spawns the AVAudioEngine
2
+ // sidecar (src/native/mic_capture.swift, `mlx-bun-mic-capture`), yields
3
+ // 16 kHz float32 PCM chunks and hotkey down/up events. Resolution mirrors
4
+ // the video frame extractor: env override → beside the binary → the
5
+ // native-pack dir → dev-compile from source with swiftc.
6
+
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { nativeRuntimeDir } from "../native-pack";
10
+ import { runtimeValue } from "../runtime-config";
11
+
12
+ const BIN = "mlx-bun-mic-capture";
13
+ let devCompile: Promise<string | null> | null = null;
14
+
15
+ function compileFromSource(): Promise<string | null> {
16
+ devCompile ??= (async () => {
17
+ const src = join(import.meta.dir, "..", "native", "mic_capture.swift");
18
+ if (!existsSync(src)) return null;
19
+ const out = join(import.meta.dir, "..", "..", "dist-native", BIN);
20
+ try {
21
+ mkdirSync(dirname(out), { recursive: true });
22
+ const proc = Bun.spawn(["swiftc", "-O", src, "-o", out], { stdout: "ignore", stderr: "pipe" });
23
+ if ((await proc.exited) === 0 && existsSync(out)) return out;
24
+ } catch { /* no swiftc */ }
25
+ return null;
26
+ })();
27
+ return devCompile;
28
+ }
29
+
30
+ export async function resolveMicCapture(): Promise<string | null> {
31
+ const explicit = runtimeValue("MLX_BUN_MIC_CAPTURE");
32
+ if (explicit) return explicit;
33
+ const beside = join(dirname(process.execPath), BIN);
34
+ if (existsSync(beside)) return beside;
35
+ const dir = nativeRuntimeDir();
36
+ if (dir && existsSync(join(dir, BIN))) return join(dir, BIN);
37
+ const dev = join(import.meta.dir, "..", "..", "dist-native", BIN);
38
+ if (existsSync(dev)) return dev;
39
+ return compileFromSource();
40
+ }
41
+
42
+ export type MicEvent =
43
+ | { kind: "ready"; info: string }
44
+ | { kind: "pcm"; samples: Float32Array }
45
+ | { kind: "hotkey"; down: boolean }
46
+ | { kind: "error"; message: string };
47
+
48
+ export interface MicCapture {
49
+ events: AsyncIterable<MicEvent>;
50
+ stop(): void;
51
+ }
52
+
53
+ /** Start capturing. `hotkey` is a macOS virtual keycode (61 = Right Option). */
54
+ export async function startMicCapture(opts: { hotkey?: number | null } = {}): Promise<MicCapture> {
55
+ const bin = await resolveMicCapture();
56
+ if (!bin) throw new Error("microphone capture needs the mlx-bun-mic-capture sidecar (build: scripts/build-mic-capture.sh, needs swiftc)");
57
+ const args = [bin, "--rate", "16000"];
58
+ if (opts.hotkey != null) args.push("--hotkey", String(opts.hotkey));
59
+ const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
60
+ const queue: MicEvent[] = [];
61
+ const waiter: { fn: (() => void) | null } = { fn: null };
62
+ let done = false;
63
+ const push = (e: MicEvent) => { queue.push(e); const w = waiter.fn; waiter.fn = null; w?.(); };
64
+ (async () => {
65
+ let carry = new Uint8Array(0);
66
+ for await (const chunk of proc.stdout) {
67
+ const buf = new Uint8Array(carry.length + chunk.length);
68
+ buf.set(carry); buf.set(chunk, carry.length);
69
+ const usable = buf.length - (buf.length % 4);
70
+ if (usable > 0) push({ kind: "pcm", samples: new Float32Array(buf.buffer.slice(0, usable)) });
71
+ carry = buf.subarray(usable);
72
+ }
73
+ done = true; const w = waiter.fn; waiter.fn = null; w?.();
74
+ })();
75
+ (async () => {
76
+ let text = "";
77
+ for await (const chunk of proc.stderr) {
78
+ text += new TextDecoder().decode(chunk);
79
+ let nl: number;
80
+ while ((nl = text.indexOf("\n")) >= 0) {
81
+ const line = text.slice(0, nl).trim(); text = text.slice(nl + 1);
82
+ if (line.startsWith("ready")) push({ kind: "ready", info: line.slice(5).trim() });
83
+ else if (line === "hotkey down") push({ kind: "hotkey", down: true });
84
+ else if (line === "hotkey up") push({ kind: "hotkey", down: false });
85
+ else if (line.startsWith("error:")) push({ kind: "error", message: line.slice(6).trim() });
86
+ }
87
+ }
88
+ })();
89
+ const events: AsyncIterable<MicEvent> = {
90
+ async *[Symbol.asyncIterator]() {
91
+ while (!done || queue.length) {
92
+ if (queue.length) { yield queue.shift()!; continue; }
93
+ await new Promise<void>((r) => { waiter.fn = r; });
94
+ }
95
+ },
96
+ };
97
+ return { events, stop: () => { try { proc.stdin.end(); } catch { /* gone */ } proc.kill(); } };
98
+ }
@@ -0,0 +1,379 @@
1
+ // Silero VAD v6 (16 kHz) — the voice-activity gate sotto runs before Whisper:
2
+ // per 512-sample chunk (32 ms) a speech probability, then the reference
3
+ // `get_speech_timestamps` state machine. Ported from the silero-vad 6.2.1
4
+ // TorchScript graph (NOT whisper.cpp's approximation, which drops the
5
+ // 64-sample inter-chunk context and pads both sides):
6
+ // x = [context(64) ‖ chunk(512)] → ReflectionPad1d(0, 64) → conv1d with
7
+ // the STFT basis (258 filters, 256 taps, hop 128) → |re, im| → [129, 4]
8
+ // → 4 × (Conv1d k3 + ReLU; strides 1,2,2,1; channels 128,64,64,128) → [128]
9
+ // → LSTMCell(128) → ReLU → Conv1d(128→1) → sigmoid.
10
+ // Weights: ggml-org/whisper-vad's ggml-silero-v6.2.0.bin (885 KB; conv and
11
+ // STFT weights are stored f16 there, so probabilities differ from the f32
12
+ // reference by ~1e-3 — parity test tolerance; segments match).
13
+ // Execution: the STFT, the conv encoder and the LSTM input projection run
14
+ // batched over ALL chunks in one MLX graph; only the 128-wide recurrence
15
+ // runs per chunk, on the host (65 K MACs each).
16
+
17
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
18
+ import { MlxArray } from "../mlx/array";
19
+ import { Dtype } from "../mlx/ffi";
20
+ import * as ops from "../mlx/ops";
21
+
22
+ export const VAD_SAMPLE_RATE = 16_000;
23
+ export const VAD_WINDOW = 512;
24
+ export const VAD_CONTEXT = 64;
25
+ const STFT_FILTERS = 258;
26
+ const STFT_TAPS = 256;
27
+ const STFT_HOP = 128;
28
+ const STFT_PAD_RIGHT = 64;
29
+ const LSTM_HIDDEN = 128;
30
+
31
+ export interface VadStreamState {
32
+ h: Float32Array;
33
+ c: Float32Array;
34
+ /** Last 64 samples of the previous call (null before the first). */
35
+ context: Float32Array | null;
36
+ chunks: number;
37
+ }
38
+
39
+ export interface VadSegment {
40
+ /** Sample offsets at 16 kHz. */
41
+ start: number;
42
+ end: number;
43
+ }
44
+
45
+ export interface SpeechTimestampOptions {
46
+ threshold?: number;
47
+ minSpeechDurationMs?: number;
48
+ maxSpeechDurationS?: number;
49
+ minSilenceDurationMs?: number;
50
+ speechPadMs?: number;
51
+ negThreshold?: number | null;
52
+ minSilenceAtMaxSpeechMs?: number;
53
+ useMaxPossibleSilenceAtMaxSpeech?: boolean;
54
+ }
55
+
56
+ interface Tensor {
57
+ data: Float32Array;
58
+ shape: number[];
59
+ }
60
+
61
+ /** Parse whisper.cpp's ggml Silero container (models/convert-silero-vad-to-ggml.py). */
62
+ export function parseSileroGgml(bytes: Uint8Array): { tensors: Map<string, Tensor>; version: string } {
63
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
64
+ let o = 0;
65
+ const i32 = () => { const v = dv.getInt32(o, true); o += 4; return v; };
66
+ if (i32() !== 0x67676d6c) throw new Error("silero vad: not a ggml file");
67
+ const typeLen = i32();
68
+ const modelType = new TextDecoder().decode(bytes.subarray(o, o + typeLen)); o += typeLen;
69
+ if (modelType !== "silero-16k") throw new Error(`silero vad: unexpected model type ${modelType}`);
70
+ const version = `${i32()}.${i32()}.${i32()}`;
71
+ const window = i32(); const context = i32();
72
+ if (window !== VAD_WINDOW || context !== VAD_CONTEXT) throw new Error(`silero vad: window ${window}/context ${context} unsupported`);
73
+ const nLayers = i32();
74
+ for (let l = 0; l < nLayers; l++) { i32(); i32(); i32(); }
75
+ i32(); i32(); i32(); i32(); // lstm in/hidden, final in/out
76
+ const tensors = new Map<string, Tensor>();
77
+ while (o < bytes.byteLength) {
78
+ const nDims = i32(); const nameLen = i32(); const ftype = i32();
79
+ const dims: number[] = [];
80
+ for (let d = 0; d < nDims; d++) dims.push(i32());
81
+ const name = new TextDecoder().decode(bytes.subarray(o, o + nameLen)); o += nameLen;
82
+ const n = dims.reduce((a, b) => a * b, 1);
83
+ const shape = [...dims].reverse(); // ggml ne[] is reversed row-major
84
+ let data: Float32Array;
85
+ if (ftype === 1) {
86
+ data = new Float32Array(n);
87
+ for (let i = 0; i < n; i++) data[i] = f16ToF32(dv.getUint16(o + 2 * i, true));
88
+ o += 2 * n;
89
+ } else {
90
+ data = new Float32Array(n);
91
+ for (let i = 0; i < n; i++) data[i] = dv.getFloat32(o + 4 * i, true);
92
+ o += 4 * n;
93
+ }
94
+ tensors.set(name, { data, shape });
95
+ }
96
+ return { tensors, version };
97
+ }
98
+
99
+ function f16ToF32(h: number): number {
100
+ const s = (h & 0x8000) ? -1 : 1;
101
+ const e = (h >> 10) & 0x1f;
102
+ const f = h & 0x3ff;
103
+ if (e === 0) return s * 2 ** -14 * (f / 1024);
104
+ if (e === 0x1f) return f ? Number.NaN : s * Infinity;
105
+ return s * 2 ** (e - 15) * (1 + f / 1024);
106
+ }
107
+
108
+ /** HF-cache location of ggml-org/whisper-vad's ggml-silero-v6.2.0.bin, or null. */
109
+ export function resolveSileroVadPath(explicit?: string | null): string | null {
110
+ if (explicit) return existsSync(explicit) ? explicit : null;
111
+ const base = `${process.env.HOME}/.cache/huggingface/hub/models--ggml-org--whisper-vad/snapshots`;
112
+ try {
113
+ for (const snap of readdirSync(base)) {
114
+ const p = `${base}/${snap}/ggml-silero-v6.2.0.bin`;
115
+ if (existsSync(p)) return p;
116
+ }
117
+ } catch { /* absent */ }
118
+ return null;
119
+ }
120
+
121
+ export class SileroVad {
122
+ readonly version: string;
123
+ // device weights (MLX layouts)
124
+ readonly #stftW: MlxArray; // [258, 256, 1]
125
+ readonly #convW: MlxArray[] = []; // [out, k, in]
126
+ readonly #convB: MlxArray[] = []; // [out]
127
+ readonly #convStride = [1, 2, 2, 1];
128
+ readonly #wIhT: MlxArray; // [128, 512]
129
+ readonly #bIhHh: MlxArray; // [512] = b_ih + b_hh
130
+ // host weights for the recurrence
131
+ readonly #wHh: Float32Array; // [512, 128]
132
+ readonly #wFinal: Float32Array; // [128]
133
+ readonly #bFinal: number;
134
+
135
+ private constructor(tensors: Map<string, Tensor>, version: string) {
136
+ this.version = version;
137
+ const t = (name: string): Tensor => {
138
+ const v = tensors.get(name);
139
+ if (!v) throw new Error(`silero vad: missing tensor ${name}`);
140
+ return v;
141
+ };
142
+ // STFT basis: torch (258, 1, 256) → MLX conv weight [out=258, k=256, in=1]
143
+ this.#stftW = MlxArray.fromFloat32(t("_model.stft.forward_basis_buffer").data, [STFT_FILTERS, STFT_TAPS, 1]);
144
+ for (let l = 0; l < 4; l++) {
145
+ const w = t(`_model.encoder.${l}.reparam_conv.weight`); // torch (out, in, 3)
146
+ const [out, inC, k] = w.shape as [number, number, number];
147
+ const mlx = new Float32Array(out * k * inC);
148
+ for (let oc = 0; oc < out; oc++) for (let ic = 0; ic < inC; ic++) for (let kk = 0; kk < k; kk++)
149
+ mlx[(oc * k + kk) * inC + ic] = w.data[(oc * inC + ic) * k + kk]!;
150
+ this.#convW.push(MlxArray.fromFloat32(mlx, [out, k, inC]));
151
+ this.#convB.push(MlxArray.fromFloat32(t(`_model.encoder.${l}.reparam_conv.bias`).data, [out]));
152
+ }
153
+ const wIh = t("_model.decoder.rnn.weight_ih"); // (512, 128)
154
+ const wIhT = new Float32Array(LSTM_HIDDEN * 4 * LSTM_HIDDEN);
155
+ for (let r = 0; r < 4 * LSTM_HIDDEN; r++) for (let c = 0; c < LSTM_HIDDEN; c++) wIhT[c * 4 * LSTM_HIDDEN + r] = wIh.data[r * LSTM_HIDDEN + c]!;
156
+ this.#wIhT = MlxArray.fromFloat32(wIhT, [LSTM_HIDDEN, 4 * LSTM_HIDDEN]);
157
+ const bIh = t("_model.decoder.rnn.bias_ih").data;
158
+ const bHh = t("_model.decoder.rnn.bias_hh").data;
159
+ const b = new Float32Array(4 * LSTM_HIDDEN);
160
+ for (let i = 0; i < b.length; i++) b[i] = bIh[i]! + bHh[i]!;
161
+ this.#bIhHh = MlxArray.fromFloat32(b, [4 * LSTM_HIDDEN]);
162
+ this.#wHh = t("_model.decoder.rnn.weight_hh").data;
163
+ this.#wFinal = t("_model.decoder.decoder.2.weight").data;
164
+ this.#bFinal = t("_model.decoder.decoder.2.bias").data[0]!;
165
+ ops.evalAll([this.#stftW, ...this.#convW, ...this.#convB, this.#wIhT, this.#bIhHh]);
166
+ }
167
+
168
+ static load(path?: string | null): SileroVad {
169
+ const p = resolveSileroVadPath(path);
170
+ if (!p)
171
+ throw new Error(
172
+ "Silero VAD weights not found: download ggml-org/whisper-vad (ggml-silero-v6.2.0.bin, 885 KB) " +
173
+ "into the HF cache or pass --vad-model <path>",
174
+ );
175
+ const { tensors, version } = parseSileroGgml(new Uint8Array(readFileSync(p)));
176
+ return new SileroVad(tensors, version);
177
+ }
178
+
179
+ /** Speech probability per 512-sample chunk (the last chunk zero-padded). */
180
+ probs(samples: Float32Array): Float32Array {
181
+ return this.probsStreaming(samples, null);
182
+ }
183
+
184
+ /** Streaming variant: `state` (LSTM h/c + the previous chunk's 64-sample
185
+ * context) carries across calls; pass null to start fresh. Feed whole
186
+ * 512-sample multiples per call (a trailing partial chunk is zero-padded
187
+ * and ends the stream like the reference). */
188
+ probsStreaming(samples: Float32Array, state: VadStreamState | null): Float32Array {
189
+ const n = Math.ceil(samples.length / VAD_WINDOW);
190
+ if (n === 0) return new Float32Array(0);
191
+ const hState = state?.h ?? new Float32Array(LSTM_HIDDEN);
192
+ const cState = state?.c ?? new Float32Array(LSTM_HIDDEN);
193
+ const prevContext = state?.context ?? null;
194
+ // [n, 640]: context (previous chunk's last 64 samples) ‖ chunk ‖ reflect pad 64
195
+ const W = VAD_CONTEXT + VAD_WINDOW + STFT_PAD_RIGHT;
196
+ const x = new Float32Array(n * W);
197
+ for (let c = 0; c < n; c++) {
198
+ const row = c * W;
199
+ const start = c * VAD_WINDOW;
200
+ for (let i = 0; i < VAD_CONTEXT; i++) {
201
+ const src = start - VAD_CONTEXT + i;
202
+ x[row + i] = src >= 0 ? samples[src]! : (prevContext ? prevContext[VAD_CONTEXT + src]! : 0);
203
+ }
204
+ for (let i = 0; i < VAD_WINDOW; i++) x[row + VAD_CONTEXT + i] = samples[start + i] ?? 0;
205
+ // reflect (torch ReflectionPad1d): pad[i] = x[L - 2 - i]
206
+ const L = VAD_CONTEXT + VAD_WINDOW;
207
+ for (let i = 0; i < STFT_PAD_RIGHT; i++) x[row + L + i] = x[row + L - 2 - i]!;
208
+ }
209
+ const inp = MlxArray.fromFloat32(x, [n, W, 1]);
210
+ let h = ops.conv1d(inp, this.#stftW, STFT_HOP, 0); // [n, 4, 258]
211
+ inp.dispose();
212
+ const cutoff = STFT_FILTERS / 2; // 129
213
+ const re = h.slice([0, 0, 0], [n, 4, cutoff]);
214
+ const im = h.slice([0, 0, cutoff], [n, 4, STFT_FILTERS]);
215
+ h.dispose();
216
+ const re2 = ops.square(re); const im2 = ops.square(im);
217
+ re.dispose(); im.dispose();
218
+ let mag = ops.add(re2, im2);
219
+ re2.dispose(); im2.dispose();
220
+ mag = dispose(mag, ops.sqrt(mag)); // [n, 4, 129]
221
+ let cur = mag;
222
+ const zero = MlxArray.fromFloat32(new Float32Array([0]), []);
223
+ for (let l = 0; l < 4; l++) {
224
+ cur = dispose(cur, ops.conv1d(cur, this.#convW[l]!, this.#convStride[l]!, 1));
225
+ cur = dispose(cur, ops.add(cur, this.#convB[l]!));
226
+ cur = dispose(cur, ops.maximum(cur, zero)); // ReLU
227
+ }
228
+ zero.dispose();
229
+ // cur: [n, 1, 128] → input gates [n, 512]
230
+ cur = dispose(cur, ops.reshape(cur, [n, LSTM_HIDDEN]));
231
+ let gates = ops.addmm(this.#bIhHh, cur, this.#wIhT);
232
+ cur.dispose();
233
+ const g = gates.toFloat32();
234
+ gates.dispose();
235
+ // host recurrence
236
+ const H = LSTM_HIDDEN;
237
+ const pre = new Float32Array(4 * H);
238
+ const out = new Float32Array(n);
239
+ const wHh = this.#wHh;
240
+ for (let t = 0; t < n; t++) {
241
+ const gi = t * 4 * H;
242
+ for (let r = 0; r < 4 * H; r++) {
243
+ let acc = g[gi + r]!;
244
+ const wr = r * H;
245
+ for (let c = 0; c < H; c++) acc += wHh[wr + c]! * hState[c]!;
246
+ pre[r] = acc;
247
+ }
248
+ for (let j = 0; j < H; j++) {
249
+ const i = sigmoid(pre[j]!);
250
+ const f = sigmoid(pre[H + j]!);
251
+ const gg = Math.tanh(pre[2 * H + j]!);
252
+ const o = sigmoid(pre[3 * H + j]!);
253
+ const c = f * cState[j]! + i * gg;
254
+ cState[j] = c;
255
+ hState[j] = o * Math.tanh(c);
256
+ }
257
+ let acc = this.#bFinal;
258
+ for (let j = 0; j < H; j++) acc += this.#wFinal[j]! * Math.max(0, hState[j]!);
259
+ out[t] = sigmoid(acc);
260
+ }
261
+ if (state) {
262
+ state.h = hState;
263
+ state.c = cState;
264
+ const ctx = new Float32Array(VAD_CONTEXT);
265
+ const tail = n * VAD_WINDOW - VAD_CONTEXT;
266
+ for (let i = 0; i < VAD_CONTEXT; i++) ctx[i] = samples[tail + i] ?? 0;
267
+ state.context = ctx;
268
+ state.chunks += n;
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /** Fresh streaming state. */
274
+ static streamState(): VadStreamState {
275
+ return { h: new Float32Array(LSTM_HIDDEN), c: new Float32Array(LSTM_HIDDEN), context: null, chunks: 0 };
276
+ }
277
+
278
+ /** get_speech_timestamps over the probabilities (sample offsets). */
279
+ static speechTimestamps(probs: Float32Array, audioLengthSamples: number, opts: SpeechTimestampOptions = {}): VadSegment[] {
280
+ const sr = VAD_SAMPLE_RATE;
281
+ const window = VAD_WINDOW;
282
+ const threshold = opts.threshold ?? 0.5;
283
+ const minSpeech = sr * (opts.minSpeechDurationMs ?? 250) / 1000;
284
+ const speechPad = sr * (opts.speechPadMs ?? 30) / 1000;
285
+ const maxSpeechS = opts.maxSpeechDurationS ?? Infinity;
286
+ const maxSpeech = sr * maxSpeechS - window - 2 * speechPad;
287
+ const minSilence = sr * (opts.minSilenceDurationMs ?? 100) / 1000;
288
+ const minSilenceAtMax = sr * (opts.minSilenceAtMaxSpeechMs ?? 98) / 1000;
289
+ const useMaxPoss = opts.useMaxPossibleSilenceAtMaxSpeech ?? true;
290
+ const negThreshold = opts.negThreshold ?? Math.max(threshold - 0.15, 0.01);
291
+ let triggered = false;
292
+ const speeches: { start: number; end: number }[] = [];
293
+ let current: { start: number; end?: number } | null = null;
294
+ let tempEnd = 0; let prevEnd = 0; let nextStart = 0;
295
+ let possibleEnds: [number, number][] = [];
296
+ for (let i = 0; i < probs.length; i++) {
297
+ const p = probs[i]!;
298
+ const cur = window * i;
299
+ if (p >= threshold && tempEnd) {
300
+ const silDur = cur - tempEnd;
301
+ if (silDur > minSilenceAtMax) possibleEnds.push([tempEnd, silDur]);
302
+ tempEnd = 0;
303
+ if (nextStart < prevEnd) nextStart = cur;
304
+ }
305
+ if (p >= threshold && !triggered) { triggered = true; current = { start: cur }; continue; }
306
+ if (triggered && current && cur - current.start > maxSpeech) {
307
+ if (useMaxPoss && possibleEnds.length) {
308
+ let best = possibleEnds[0]!;
309
+ for (const pe of possibleEnds) if (pe[1] > best[1]) best = pe;
310
+ const [pEnd, dur] = best;
311
+ prevEnd = pEnd;
312
+ speeches.push({ start: current.start, end: prevEnd });
313
+ current = null;
314
+ nextStart = prevEnd + dur;
315
+ if (nextStart < prevEnd + cur) current = { start: nextStart };
316
+ else triggered = false;
317
+ prevEnd = nextStart = tempEnd = 0;
318
+ possibleEnds = [];
319
+ } else if (prevEnd) {
320
+ speeches.push({ start: current.start, end: prevEnd });
321
+ current = null;
322
+ if (nextStart < prevEnd) triggered = false; else current = { start: nextStart };
323
+ prevEnd = nextStart = tempEnd = 0;
324
+ possibleEnds = [];
325
+ } else {
326
+ speeches.push({ start: current.start, end: cur });
327
+ current = null;
328
+ prevEnd = nextStart = tempEnd = 0;
329
+ triggered = false;
330
+ possibleEnds = [];
331
+ continue;
332
+ }
333
+ }
334
+ if (p < negThreshold && triggered && current) {
335
+ if (!tempEnd) tempEnd = cur;
336
+ const silNow = cur - tempEnd;
337
+ if (!useMaxPoss && silNow > minSilenceAtMax) prevEnd = tempEnd;
338
+ if (silNow < minSilence) continue;
339
+ if (tempEnd - current.start > minSpeech) speeches.push({ start: current.start, end: tempEnd });
340
+ current = null;
341
+ prevEnd = nextStart = tempEnd = 0;
342
+ triggered = false;
343
+ possibleEnds = [];
344
+ continue;
345
+ }
346
+ }
347
+ if (current && audioLengthSamples - current.start > minSpeech) speeches.push({ start: current.start, end: audioLengthSamples });
348
+ for (let i = 0; i < speeches.length; i++) {
349
+ const s = speeches[i]!;
350
+ if (i === 0) s.start = Math.floor(Math.max(0, s.start - speechPad));
351
+ if (i !== speeches.length - 1) {
352
+ const nxt = speeches[i + 1]!;
353
+ const silence = nxt.start - s.end;
354
+ if (silence < 2 * speechPad) {
355
+ s.end += Math.floor(silence / 2);
356
+ nxt.start = Math.floor(Math.max(0, nxt.start - Math.floor(silence / 2)));
357
+ } else {
358
+ s.end = Math.floor(Math.min(audioLengthSamples, s.end + speechPad));
359
+ nxt.start = Math.floor(Math.max(0, nxt.start - speechPad));
360
+ }
361
+ } else s.end = Math.floor(Math.min(audioLengthSamples, s.end + speechPad));
362
+ }
363
+ return speeches;
364
+ }
365
+
366
+ /** Probabilities + segments in one call. */
367
+ detect(samples: Float32Array, opts: SpeechTimestampOptions = {}): { probs: Float32Array; segments: VadSegment[] } {
368
+ const probs = this.probs(samples);
369
+ return { probs, segments: SileroVad.speechTimestamps(probs, samples.length, opts) };
370
+ }
371
+
372
+ dispose(): void {
373
+ this.#stftW.dispose();
374
+ for (const a of [...this.#convW, ...this.#convB, this.#wIhT, this.#bIhHh]) a.dispose();
375
+ }
376
+ }
377
+
378
+ const sigmoid = (x: number) => 1 / (1 + Math.exp(-x));
379
+ const dispose = (old: MlxArray, next: MlxArray): MlxArray => { old.dispose(); return next; };