mlx-bun 0.3.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -1,8 +1,10 @@
1
+ import { captureKvAttention } from "../kv-attention-view";
1
2
  // UniversalDense — the Tier-0 config-driven dense llama-family module
2
3
  // (docs/design/generic-model-support.md §3.1). One module, shaped exactly
3
4
  // like qwen3.ts, with every arch delta selected by the UniversalArgs
4
5
  // descriptor (archs.ts). Monolith path only: no compiled
5
- // decode, no kv-quant — slow, never broken. The bar is L1 bit-exactness
6
+ // decode. Standard attention uses the shared plain/affine KV port; the
7
+ // softcap branch retains plain KV. The bar is L1 bit-exactness
6
8
  // vs mlx-lm on this machine's GPU (tests/parity/universal-parity.test.ts).
7
9
  //
8
10
  // Porting discipline: each branch transcribes its mlx-lm source op-for-op
@@ -154,19 +156,18 @@ class UniversalAttention {
154
156
  ? this.rope.applyDynamic(k, offArr)
155
157
  : this.rope.apply(k, cache.offset));
156
158
 
157
- const [keys, values] = cache.updateAndFetch(k, v);
158
- k.dispose();
159
- v.dispose();
160
-
161
159
  let attn: MlxArray;
162
- if (a.attnLogitSoftcap !== null) {
163
- attn = this.#softcapAttention(q, keys, values, mask, B, L);
164
- } else {
165
- attn = ops.sdpa(q, keys, values, a.attnScale, mask.mode, mask.arr);
166
- }
167
- keys.dispose();
168
- values.dispose();
169
- q.dispose();
160
+ try {
161
+ if (a.attnLogitSoftcap !== null) {
162
+ const [keys, values] = cache.updateAndFetch(k, v);
163
+ try { attn = this.#softcapAttention(q, keys, values, mask, B, L); }
164
+ finally { keys.dispose(); values.dispose(); }
165
+ } else {
166
+ const view = captureKvAttention(cache, k, v);
167
+ try { attn = view.attend(q, a.attnScale, mask); }
168
+ finally { view.dispose(); }
169
+ }
170
+ } finally { k.dispose(); v.dispose(); q.dispose(); }
170
171
 
171
172
  const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
172
173
  attn.dispose();
@@ -0,0 +1,435 @@
1
+ // Whisper (encoder-decoder speech recognition) — an op-for-op port of
2
+ // mlx-whisper's whisper.py (mlx-community/whisper-* checkpoints: fp16
3
+ // weights in a single weights.safetensors + config.json ModelDimensions).
4
+ // L1 gate: encoder output and per-step decoder logits bit-exact vs the
5
+ // pinned mlx-whisper oracle (tests/parity/whisper.test.ts).
6
+ //
7
+ // Graph (dtype = weight dtype, fp16 for the shipped artifacts):
8
+ // encoder: mel [B,3000,n_mels] → conv1(k3,p1)+gelu → conv2(k3,s2,p1)+gelu
9
+ // → + sinusoids(1500, D) → N × block(self-attn, mlp) → ln_post
10
+ // decoder: tokens [B,T] → token_embedding + positional_embedding[offset:]
11
+ // → N × block(causal self-attn w/ cache, cross-attn on the
12
+ // encoder output w/ cross-KV cache, mlp) → ln → @ embeddingᵀ
13
+ // gelu: mlx.nn.gelu is `@partial(mx.compile, shapeless=True)` in the
14
+ // oracle — the FUSED kernel rounds differently from the four
15
+ // separate ops (2% of conv1 outputs differ by an ulp), so the same
16
+ // graph is compiled here too (bit-exact, verified 2026-09-15).
17
+ // attention: q,k scaled by (D/H)^-0.25 each, qk in the weight dtype,
18
+ // additive causal mask (-inf f16), precise softmax, no fused
19
+ // kernels — the FAITHFUL path. The optimized path lives in
20
+ // src/audio/whisper-fast.ts and is gated token-exact against
21
+ // this one.
22
+ //
23
+ // mlx inline-temporary hazard (CLAUDE.md): every op result is held in a
24
+ // local and disposed; no nested `ops.foo(x.slice(...))` chains.
25
+
26
+ import type { ModelConfig } from "../config";
27
+ import { MlxArray } from "../mlx/array";
28
+ import { CompiledFunction } from "../mlx/compile";
29
+ import { WhisperFastPath } from "../audio/whisper-fast";
30
+ import { Dtype } from "../mlx/ffi";
31
+ import * as ops from "../mlx/ops";
32
+ import type { Weights } from "../weights";
33
+
34
+ export interface WhisperDims {
35
+ nMels: number;
36
+ nAudioCtx: number;
37
+ nAudioState: number;
38
+ nAudioHead: number;
39
+ nAudioLayer: number;
40
+ nVocab: number;
41
+ nTextCtx: number;
42
+ nTextState: number;
43
+ nTextHead: number;
44
+ nTextLayer: number;
45
+ }
46
+
47
+ export function parseWhisperDims(raw: Record<string, unknown>): WhisperDims {
48
+ const n = (k: string): number => {
49
+ const v = raw[k];
50
+ if (typeof v !== "number") throw new Error(`whisper config.json: missing numeric ${k}`);
51
+ return v;
52
+ };
53
+ return {
54
+ nMels: n("n_mels"), nAudioCtx: n("n_audio_ctx"), nAudioState: n("n_audio_state"),
55
+ nAudioHead: n("n_audio_head"), nAudioLayer: n("n_audio_layer"), nVocab: n("n_vocab"),
56
+ nTextCtx: n("n_text_ctx"), nTextState: n("n_text_state"), nTextHead: n("n_text_head"),
57
+ nTextLayer: n("n_text_layer"),
58
+ };
59
+ }
60
+
61
+ const dispose = (old: MlxArray, next: MlxArray): MlxArray => {
62
+ old.dispose();
63
+ return next;
64
+ };
65
+
66
+ /** Per-layer decoder state: self-attention K/V [B, T, D] (un-split, un-scaled,
67
+ * exactly what the oracle concatenates) and the cross-attention K/V computed
68
+ * once from the encoder output. */
69
+ export interface WhisperLayerCache {
70
+ k: MlxArray | null;
71
+ v: MlxArray | null;
72
+ crossK: MlxArray | null;
73
+ crossV: MlxArray | null;
74
+ }
75
+
76
+ export class WhisperKvCache {
77
+ readonly layers: WhisperLayerCache[];
78
+ constructor(nLayers: number) {
79
+ this.layers = Array.from({ length: nLayers }, () => ({ k: null, v: null, crossK: null, crossV: null }));
80
+ }
81
+ /** Tokens already in the self-attention cache. */
82
+ get offset(): number {
83
+ return this.layers[0]?.k?.shape[1] ?? 0;
84
+ }
85
+ /** Beam reorder: keep rows `indices` (mx.take along axis 0). Cross K/V
86
+ * rows are identical across beams and are left untouched when they were
87
+ * broadcast from one audio (shape[0] === 1). */
88
+ rearrange(indices: number[]): void {
89
+ if (indices.every((v, i) => v === i)) return;
90
+ const idx = ops.fromInt32(indices, [indices.length]);
91
+ for (const l of this.layers) {
92
+ if (l.k) l.k = dispose(l.k, ops.takeAxis(l.k, idx, 0));
93
+ if (l.v) l.v = dispose(l.v, ops.takeAxis(l.v, idx, 0));
94
+ if (l.crossK && l.crossK.shape[0] !== 1) l.crossK = dispose(l.crossK, ops.takeAxis(l.crossK, idx, 0));
95
+ if (l.crossV && l.crossV.shape[0] !== 1) l.crossV = dispose(l.crossV, ops.takeAxis(l.crossV, idx, 0));
96
+ }
97
+ idx.dispose();
98
+ }
99
+ arrays(): MlxArray[] {
100
+ const out: MlxArray[] = [];
101
+ for (const l of this.layers) for (const a of [l.k, l.v, l.crossK, l.crossV]) if (a) out.push(a);
102
+ return out;
103
+ }
104
+ dispose(): void {
105
+ for (const l of this.layers) {
106
+ l.k?.dispose(); l.v?.dispose(); l.crossK?.dispose(); l.crossV?.dispose();
107
+ l.k = l.v = l.crossK = l.crossV = null;
108
+ }
109
+ }
110
+ }
111
+
112
+ export class WhisperModel {
113
+ readonly dims: WhisperDims;
114
+ readonly dtype: Dtype;
115
+ readonly #w: Weights;
116
+ /** [1500, D] encoder positional sinusoids (weight dtype). */
117
+ #encPos: MlxArray | null = null;
118
+ /** [n_text_ctx, n_text_ctx] additive causal mask (weight dtype, -inf). */
119
+ #causalMask: MlxArray | null = null;
120
+ /** Transposed weight views, cached per name. */
121
+ readonly #transposed = new Map<string, MlxArray>();
122
+ /** Compiled nn.gelu (see header). */
123
+ #gelu: CompiledFunction | null = null;
124
+ #fast: WhisperFastPath | null = null;
125
+
126
+ /** The optimized execution path (src/audio/whisper-fast.ts), built lazily. */
127
+ get fast(): WhisperFastPath {
128
+ return this.#fast ??= new WhisperFastPath(this);
129
+ }
130
+
131
+ constructor(weights: Weights, config: ModelConfig) {
132
+ this.#w = weights;
133
+ this.dims = parseWhisperDims(config.raw as Record<string, unknown>);
134
+ this.dtype = weights.tensor("decoder.token_embedding.weight").dtype;
135
+ }
136
+
137
+ get isMultilingual(): boolean {
138
+ return this.dims.nVocab >= 51865;
139
+ }
140
+ get numLanguages(): number {
141
+ return this.dims.nVocab - 51765 - (this.isMultilingual ? 1 : 0);
142
+ }
143
+
144
+ /** Alignment heads [(layer, head), …] from the checkpoint (word timestamps);
145
+ * falls back to every head of the last half of the decoder layers. */
146
+ get alignmentHeads(): [number, number][] {
147
+ if (this.#w.has("alignment_heads")) {
148
+ const a = this.#w.tensor("alignment_heads");
149
+ const flat = a.astype(Dtype.int32).toIntTokens();
150
+ const out: [number, number][] = [];
151
+ for (let i = 0; i + 1 < flat.length; i += 2) out.push([flat[i]!, flat[i + 1]!]);
152
+ return out;
153
+ }
154
+ const out: [number, number][] = [];
155
+ for (let l = Math.floor(this.dims.nTextLayer / 2); l < this.dims.nTextLayer; l++)
156
+ for (let h = 0; h < this.dims.nTextHead; h++) out.push([l, h]);
157
+ return out;
158
+ }
159
+
160
+ // --- primitives -----------------------------------------------------------
161
+
162
+ #t(name: string): MlxArray {
163
+ return this.#w.tensor(name);
164
+ }
165
+
166
+ /** Raw checkpoint tensor (borrowed; the fast path composes its own graphs). */
167
+ weight(name: string): MlxArray {
168
+ return this.#t(name);
169
+ }
170
+
171
+ /** Cached Wᵀ view (borrowed). */
172
+ weightT(name: string): MlxArray {
173
+ return this.#wT(name);
174
+ }
175
+
176
+ /** The compiled nn.gelu (shared with the faithful path). */
177
+ gelu(x: MlxArray): MlxArray {
178
+ return this.#geluCompiled(x);
179
+ }
180
+
181
+ layerNorm(x: MlxArray, prefix: string): MlxArray {
182
+ return this.#layerNorm(x, prefix);
183
+ }
184
+
185
+ /** nn.Linear against a checkpoint weight (borrowed inputs, owned output). */
186
+ linear(x: MlxArray, prefix: string, bias = true): MlxArray {
187
+ return this.#linear(x, prefix, bias);
188
+ }
189
+
190
+ /** Wᵀ view, cached (transpose is a strided view, no copy). */
191
+ #wT(name: string): MlxArray {
192
+ let t = this.#transposed.get(name);
193
+ if (!t) {
194
+ t = ops.transposeAxes(this.#t(name), [1, 0]);
195
+ this.#transposed.set(name, t);
196
+ }
197
+ return t;
198
+ }
199
+
200
+ /** nn.Linear: addmm(bias, x, Wᵀ) with bias, x @ Wᵀ without. */
201
+ #linear(x: MlxArray, prefix: string, bias = true): MlxArray {
202
+ const wT = this.#wT(`${prefix}.weight`);
203
+ return bias ? ops.addmm(this.#t(`${prefix}.bias`), x, wT) : ops.matmul(x, wT);
204
+ }
205
+
206
+ /** nn.gelu — compiled, shapeless, same op graph as mlx.nn.activations.gelu:
207
+ * x * (1 + erf(x / sqrt(2))) / 2. */
208
+ #geluCompiled(x: MlxArray): MlxArray {
209
+ this.#gelu ??= new CompiledFunction(([a]) => {
210
+ const s2 = ops.scalarLike(Math.SQRT2, a!);
211
+ const xs = ops.div(a!, s2);
212
+ const e = ops.erf(xs);
213
+ const one = ops.scalarLike(1, a!);
214
+ const e1 = ops.add(one, e);
215
+ const xe = ops.mul(a!, e1);
216
+ const two = ops.scalarLike(2, a!);
217
+ return [ops.div(xe, two)];
218
+ }, true);
219
+ return this.#gelu.apply([x])[0]!;
220
+ }
221
+
222
+ #layerNorm(x: MlxArray, prefix: string): MlxArray {
223
+ return ops.layerNorm(x, this.#t(`${prefix}.weight`), this.#t(`${prefix}.bias`), 1e-5);
224
+ }
225
+
226
+ /** MultiHeadAttention.__call__ + qkv_attention. Returns the projected
227
+ * output, the (un-split) k/v to cache, and optionally the raw qk. */
228
+ #attention(
229
+ x: MlxArray, prefix: string, nHead: number,
230
+ opts: { xa?: MlxArray; mask?: MlxArray; kv?: [MlxArray, MlxArray] | null; crossKv?: [MlxArray, MlxArray] | null; wantQk?: boolean },
231
+ ): { out: MlxArray; k: MlxArray; v: MlxArray; qk: MlxArray | null } {
232
+ const q = this.#linear(x, `${prefix}.query`);
233
+ let k: MlxArray;
234
+ let v: MlxArray;
235
+ if (!opts.xa) {
236
+ k = this.#linear(x, `${prefix}.key`, false);
237
+ v = this.#linear(x, `${prefix}.value`);
238
+ if (opts.kv) {
239
+ k = dispose(k, ops.concatAxis([opts.kv[0], k], 1));
240
+ v = dispose(v, ops.concatAxis([opts.kv[1], v], 1));
241
+ }
242
+ } else if (!opts.crossKv) {
243
+ k = this.#linear(opts.xa, `${prefix}.key`, false);
244
+ v = this.#linear(opts.xa, `${prefix}.value`);
245
+ } else {
246
+ [k, v] = opts.crossKv;
247
+ }
248
+ const [B, T, D] = q.shape as [number, number, number];
249
+ // k/v keep THEIR batch (the oracle reshapes with k.shape): beam rows
250
+ // attend to one broadcast encoder output ([1, 1500, D]) via matmul
251
+ // broadcasting instead of copying cross-K/V per beam.
252
+ const Bk = k.shape[0]!;
253
+ const Tk = k.shape[1]!;
254
+ const hd = D / nHead;
255
+ const scale = ops.scalarLike(hd ** -0.25, q);
256
+ let qh = ops.reshape(q, [B, T, nHead, hd]);
257
+ q.dispose();
258
+ qh = dispose(qh, ops.transposeAxes(qh, [0, 2, 1, 3]));
259
+ qh = dispose(qh, ops.mul(qh, scale));
260
+ let kh = ops.reshape(k, [Bk, Tk, nHead, hd]);
261
+ kh = dispose(kh, ops.transposeAxes(kh, [0, 2, 3, 1]));
262
+ kh = dispose(kh, ops.mul(kh, scale));
263
+ scale.dispose();
264
+ let vh = ops.reshape(v, [Bk, Tk, nHead, hd]);
265
+ vh = dispose(vh, ops.transposeAxes(vh, [0, 2, 1, 3]));
266
+ let qk = ops.matmul(qh, kh);
267
+ qh.dispose();
268
+ kh.dispose();
269
+ if (opts.mask) {
270
+ const m = opts.mask.slice([0, 0], [T, T]);
271
+ qk = dispose(qk, ops.add(qk, m));
272
+ m.dispose();
273
+ }
274
+ const w = ops.softmaxAxis(qk, -1, true);
275
+ let o = ops.matmul(w, vh);
276
+ w.dispose();
277
+ vh.dispose();
278
+ o = dispose(o, ops.transposeAxes(o, [0, 2, 1, 3]));
279
+ o = dispose(o, ops.reshape(o, [B, T, D]));
280
+ const out = this.#linear(o, `${prefix}.out`);
281
+ o.dispose();
282
+ if (!opts.wantQk) {
283
+ qk.dispose();
284
+ return { out, k, v, qk: null };
285
+ }
286
+ return { out, k, v, qk };
287
+ }
288
+
289
+ #mlp(x: MlxArray, prefix: string): MlxArray {
290
+ let h = this.#layerNorm(x, `${prefix}.mlp_ln`);
291
+ h = dispose(h, this.#linear(h, `${prefix}.mlp1`));
292
+ h = dispose(h, this.#geluCompiled(h));
293
+ h = dispose(h, this.#linear(h, `${prefix}.mlp2`));
294
+ return h;
295
+ }
296
+
297
+ // --- encoder ----------------------------------------------------------------
298
+
299
+ #sinusoids(): MlxArray {
300
+ if (this.#encPos) return this.#encPos;
301
+ const { nAudioCtx: length, nAudioState: channels } = this.dims;
302
+ const half = channels / 2;
303
+ const inc = Math.log(10000) / (half - 1);
304
+ const ar = ops.arange(0, half, 1, Dtype.int32);
305
+ const negInc = MlxArray.fromFloat32(new Float32Array([-inc]), []);
306
+ let inv = ops.mul(ar, negInc);
307
+ ar.dispose();
308
+ negInc.dispose();
309
+ inv = dispose(inv, ops.exp(inv));
310
+ inv = dispose(inv, ops.reshape(inv, [1, half]));
311
+ let pos = ops.arange(0, length, 1, Dtype.int32);
312
+ pos = dispose(pos, ops.reshape(pos, [length, 1]));
313
+ const scaled = ops.mul(pos, inv);
314
+ pos.dispose();
315
+ inv.dispose();
316
+ const s = ops.sin(scaled);
317
+ const c = ops.cos(scaled);
318
+ scaled.dispose();
319
+ let pe = ops.concatAxis([s, c], 1);
320
+ s.dispose();
321
+ c.dispose();
322
+ pe = dispose(pe, pe.astype(this.dtype));
323
+ this.#encPos = pe;
324
+ return pe;
325
+ }
326
+
327
+ /** AudioEncoder: mel [B, 3000, n_mels] (weight dtype) → [B, 1500, D]. */
328
+ encode(mel: MlxArray, trace?: (name: string, a: MlxArray) => void): MlxArray {
329
+ let x = ops.conv1d(mel, this.#t("encoder.conv1.weight"), 1, 1);
330
+ x = dispose(x, ops.add(x, this.#t("encoder.conv1.bias")));
331
+ x = dispose(x, this.#geluCompiled(x));
332
+ trace?.("conv1", x);
333
+ x = dispose(x, ops.conv1d(x, this.#t("encoder.conv2.weight"), 2, 1));
334
+ x = dispose(x, ops.add(x, this.#t("encoder.conv2.bias")));
335
+ x = dispose(x, this.#geluCompiled(x));
336
+ if (x.shape[1] !== this.dims.nAudioCtx || x.shape[2] !== this.dims.nAudioState)
337
+ throw new Error(`whisper encoder: incorrect audio shape ${JSON.stringify(x.shape)}`);
338
+ x = dispose(x, ops.add(x, this.#sinusoids()));
339
+ trace?.("conv2pos", x);
340
+ for (let i = 0; i < this.dims.nAudioLayer; i++) {
341
+ const p = `encoder.blocks.${i}`;
342
+ const ln = this.#layerNorm(x, `${p}.attn_ln`);
343
+ const a = this.#attention(ln, `${p}.attn`, this.dims.nAudioHead, {});
344
+ ln.dispose();
345
+ a.k.dispose();
346
+ a.v.dispose();
347
+ x = dispose(x, ops.add(x, a.out));
348
+ a.out.dispose();
349
+ const m = this.#mlp(x, p);
350
+ x = dispose(x, ops.add(x, m));
351
+ m.dispose();
352
+ trace?.(`block${i}`, x);
353
+ }
354
+ x = dispose(x, this.#layerNorm(x, "encoder.ln_post"));
355
+ return x;
356
+ }
357
+
358
+ // --- decoder ----------------------------------------------------------------
359
+
360
+ #mask(): MlxArray {
361
+ if (this.#causalMask) return this.#causalMask;
362
+ const n = this.dims.nTextCtx;
363
+ const m = new Float32Array(n * n);
364
+ for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) m[i * n + j] = -Infinity;
365
+ const f32 = MlxArray.fromFloat32(m, [n, n]);
366
+ const out = f32.astype(this.dtype);
367
+ f32.dispose();
368
+ this.#causalMask = out;
369
+ return out;
370
+ }
371
+
372
+ /** TextDecoder.__call__: tokens [B, T] int32, audioFeatures [B|1, 1500, D].
373
+ * Returns logits [B, T, n_vocab] (weight dtype) and, when requested, the
374
+ * per-layer raw cross-attention qk for alignment. Mutates `cache`. */
375
+ decode(
376
+ tokens: MlxArray, audioFeatures: MlxArray, cache: WhisperKvCache,
377
+ opts: { wantCrossQk?: boolean } = {},
378
+ ): { logits: MlxArray; crossQk: (MlxArray | null)[] } {
379
+ const offset = cache.offset;
380
+ const T = tokens.shape[1]!;
381
+ const embed = this.#t("decoder.token_embedding.weight");
382
+ let x = ops.takeAxis(embed, tokens, 0);
383
+ const pos = this.#t("decoder.positional_embedding").slice([offset, 0], [offset + T, this.dims.nTextState]);
384
+ x = dispose(x, ops.add(x, pos));
385
+ pos.dispose();
386
+ const mask = this.#mask();
387
+ const crossQk: (MlxArray | null)[] = [];
388
+ for (let i = 0; i < this.dims.nTextLayer; i++) {
389
+ const p = `decoder.blocks.${i}`;
390
+ const layer = cache.layers[i]!;
391
+ const ln = this.#layerNorm(x, `${p}.attn_ln`);
392
+ const a = this.#attention(ln, `${p}.attn`, this.dims.nTextHead, {
393
+ mask, kv: layer.k && layer.v ? [layer.k, layer.v] : null,
394
+ });
395
+ ln.dispose();
396
+ layer.k?.dispose();
397
+ layer.v?.dispose();
398
+ layer.k = a.k;
399
+ layer.v = a.v;
400
+ x = dispose(x, ops.add(x, a.out));
401
+ a.out.dispose();
402
+ const cln = this.#layerNorm(x, `${p}.cross_attn_ln`);
403
+ const c = this.#attention(cln, `${p}.cross_attn`, this.dims.nTextHead, {
404
+ xa: audioFeatures,
405
+ crossKv: layer.crossK && layer.crossV ? [layer.crossK, layer.crossV] : null,
406
+ wantQk: opts.wantCrossQk,
407
+ });
408
+ cln.dispose();
409
+ if (!layer.crossK) {
410
+ layer.crossK = c.k;
411
+ layer.crossV = c.v;
412
+ }
413
+ x = dispose(x, ops.add(x, c.out));
414
+ c.out.dispose();
415
+ crossQk.push(c.qk);
416
+ const m = this.#mlp(x, p);
417
+ x = dispose(x, ops.add(x, m));
418
+ m.dispose();
419
+ }
420
+ x = dispose(x, this.#layerNorm(x, "decoder.ln"));
421
+ const logits = ops.matmul(x, this.#wT("decoder.token_embedding.weight"));
422
+ x.dispose();
423
+ return { logits, crossQk };
424
+ }
425
+
426
+ dispose(): void {
427
+ this.#encPos?.dispose();
428
+ this.#causalMask?.dispose();
429
+ for (const t of this.#transposed.values()) t.dispose();
430
+ this.#transposed.clear();
431
+ this.#fast?.dispose();
432
+ this.#gelu?.dispose();
433
+ this.#w.dispose();
434
+ }
435
+ }
@@ -0,0 +1,92 @@
1
+ // Microphone capture sidecar for `mlx-bun dictate`: AVAudioEngine input
2
+ // → 16 kHz mono float32 little-endian PCM on stdout, continuously, in
3
+ // 20 ms buffers. Control lines go to stderr: "ready", and with --hotkey
4
+ // <keycode>, "hotkey down" / "hotkey up" from a CGEvent listen-only tap
5
+ // (needs the terminal app to have Input Monitoring; the mic needs
6
+ // Microphone permission — macOS prompts on first use).
7
+ //
8
+ // mic-capture [--rate 16000] [--hotkey 61] (61 = Right Option)
9
+
10
+ import AVFoundation
11
+ import CoreGraphics
12
+ import Foundation
13
+
14
+ var rate: Double = 16000
15
+ var hotkey: Int64? = nil
16
+ var args = Array(CommandLine.arguments.dropFirst())
17
+ while !args.isEmpty {
18
+ let a = args.removeFirst()
19
+ switch a {
20
+ case "--rate": rate = Double(args.removeFirst()) ?? 16000
21
+ case "--hotkey": hotkey = Int64(args.removeFirst())
22
+ default:
23
+ FileHandle.standardError.write("usage: mic-capture [--rate 16000] [--hotkey <keycode>]\n".data(using: .utf8)!)
24
+ exit(2)
25
+ }
26
+ }
27
+
28
+ let err = FileHandle.standardError
29
+ let out = FileHandle.standardOutput
30
+ let writeLock = NSLock()
31
+ func note(_ s: String) { err.write((s + "\n").data(using: .utf8)!) }
32
+
33
+ var hotkeyDown = false
34
+ let engine = AVAudioEngine()
35
+ let input = engine.inputNode
36
+ let inFormat = input.outputFormat(forBus: 0)
37
+ guard let outFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1, interleaved: true),
38
+ let converter = AVAudioConverter(from: inFormat, to: outFormat) else {
39
+ note("error: cannot build audio converter"); exit(1)
40
+ }
41
+ input.installTap(onBus: 0, bufferSize: AVAudioFrameCount(inFormat.sampleRate * 0.02), format: inFormat) { buffer, _ in
42
+ let ratio = rate / inFormat.sampleRate
43
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 32
44
+ guard let outBuf = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: capacity) else { return }
45
+ var consumed = false
46
+ var convError: NSError? = nil
47
+ converter.convert(to: outBuf, error: &convError) { _, status in
48
+ if consumed { status.pointee = .noDataNow; return nil }
49
+ consumed = true
50
+ status.pointee = .haveData
51
+ return buffer
52
+ }
53
+ if convError != nil || outBuf.frameLength == 0 { return }
54
+ let bytes = Data(bytes: outBuf.floatChannelData![0], count: Int(outBuf.frameLength) * 4)
55
+ writeLock.lock(); out.write(bytes); writeLock.unlock()
56
+ }
57
+ do { try engine.start() } catch { note("error: audio engine failed: \(error)"); exit(1) }
58
+ note("ready rate=\(Int(rate)) input=\(Int(inFormat.sampleRate))Hz/\(inFormat.channelCount)ch")
59
+
60
+ if let key = hotkey {
61
+ let mask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
62
+ let callback: CGEventTapCallBack = { _, type, event, _ in
63
+ let code = event.getIntegerValueField(.keyboardEventKeycode)
64
+ if code == hotkey! {
65
+ var isDown = false
66
+ switch type {
67
+ case .keyDown: isDown = true
68
+ case .keyUp: isDown = false
69
+ case .flagsChanged:
70
+ let f = event.flags
71
+ isDown = f.contains(.maskAlternate) || f.contains(.maskCommand) || f.contains(.maskControl) || f.contains(.maskShift) || f.contains(.maskSecondaryFn)
72
+ default: return Unmanaged.passUnretained(event)
73
+ }
74
+ if isDown != hotkeyDown { hotkeyDown = isDown; note(isDown ? "hotkey down" : "hotkey up") }
75
+ }
76
+ return Unmanaged.passUnretained(event)
77
+ }
78
+ guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: mask, callback: callback, userInfo: nil) else {
79
+ note("error: cannot create event tap — grant Input Monitoring to your terminal (System Settings → Privacy & Security)")
80
+ exit(3)
81
+ }
82
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
83
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
84
+ CGEvent.tapEnable(tap: tap, enable: true)
85
+ note("hotkey \(key) armed")
86
+ }
87
+ // stdin EOF (parent gone) ends the helper
88
+ DispatchQueue.global().async {
89
+ _ = FileHandle.standardInput.readDataToEndOfFile()
90
+ exit(0)
91
+ }
92
+ CFRunLoopRun()
@@ -17,23 +17,24 @@ import { dirname, join } from "node:path";
17
17
  import { downloadOne } from "./download";
18
18
  import { runtimeValue } from "./runtime-config";
19
19
 
20
- export const NATIVE_PACK_VERSION = "0.4.0";
20
+ export const NATIVE_PACK_VERSION = "0.5.0";
21
21
  export const MLX_CORE_VERSION = "0.32.2";
22
22
  export const NATIVE_PACK_FILES = [
23
23
  "libmlxc.dylib", "libmlx.dylib", "libjaccl.dylib", "mlx.metallib",
24
- "libmlx_bun_expert_io.dylib", "mlx-bun-frame-extract",
24
+ "libmlx_bun_expert_io.dylib", "mlx-bun-frame-extract", "mlx-bun-mic-capture",
25
25
  ] as const;
26
26
 
27
27
  // RELEASE-TIME BAKE: scripts/build-native-pack.sh prints the sha256/size for
28
28
  // the freshly built tarball — publish `native-v0.4.0` with both assets and
29
- // paste the values here BEFORE tagging the package release (the download
29
+ // paste the values here BEFORE tagging the package release (0.5.0 adds the
30
+ // mlx-bun-mic-capture dictation sidecar) (the download
30
31
  // 404s until the GitHub release exists; dev trees resolve via homebrew/env
31
32
  // and never hit this path).
32
33
  const SHA256: Record<string, string> = {
33
- arm64: "f2b0857658aa0826aee14b94a6b83d4c86c08f2c07d716991de6afd038f5ca5d",
34
+ arm64: "6ff825499ca48bd4e9adebeefac78108cc4328971011c233210d65a0aa78b427",
34
35
  };
35
36
  const SIZE: Record<string, number> = {
36
- arm64: 41931583,
37
+ arm64: 41945454,
37
38
  };
38
39
 
39
40
  export function nativePackName(arch = process.arch): string {
package/src/pi-web.ts CHANGED
@@ -1014,6 +1014,7 @@ class PiWebSession {
1014
1014
  private readonly opts: {
1015
1015
  port: number | (() => number); modelId: string; contextWindow: number; readOnly: boolean;
1016
1016
  vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults;
1017
+ transcription: () => Promise<boolean>;
1017
1018
  },
1018
1019
  ) {}
1019
1020
 
@@ -1046,9 +1047,11 @@ class PiWebSession {
1046
1047
  await this.replaceRuntime(SessionManager.create(this.cwd, this.sessionDir));
1047
1048
  if (this.disposed) return;
1048
1049
 
1050
+ let transcription = false;
1051
+ try { transcription = await this.opts.transcription(); } catch { /* no whisper → no mic button */ }
1049
1052
  this.send({
1050
1053
  type: "ready", model: this.opts.modelId, vision: this.opts.vision, audio: this.opts.audio,
1051
- thinking: this.opts.thinking, genDefaults: this.opts.genDefaults,
1054
+ thinking: this.opts.thinking, genDefaults: this.opts.genDefaults, transcription,
1052
1055
  });
1053
1056
  this.sendCodingToolsState();
1054
1057
  this.sendToolApprovals();
@@ -1854,6 +1857,10 @@ export function makePiWsHandler(opts: {
1854
1857
  * overrides), sent once on the `ready` frame. Default: all-null (the
1855
1858
  * sampling popover falls back to its own hardcoded shape). */
1856
1859
  genDefaults?: ReadyGenDefaults;
1860
+ /** Whether /v1/audio/sessions can serve this browser (a Whisper checkpoint
1861
+ * is configured or resolvable). Async: the default checkpoint resolves
1862
+ * lazily through the registry. */
1863
+ transcription?: () => Promise<boolean>;
1857
1864
  }): WebSocketHandler<PiWsData> {
1858
1865
  const resolved = {
1859
1866
  port: opts.port,
@@ -1864,6 +1871,7 @@ export function makePiWsHandler(opts: {
1864
1871
  audio: opts.audio ?? false,
1865
1872
  thinking: opts.thinking ?? false,
1866
1873
  genDefaults: opts.genDefaults ?? { temperature: null, topP: null, topK: null },
1874
+ transcription: opts.transcription ?? (async () => false),
1867
1875
  };
1868
1876
 
1869
1877
  return {