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
@@ -18,10 +18,12 @@ import type { ChatMessage, ChatTemplate, ToolDefinition } from "../chat-template
18
18
  import type { LoadedTokenizer } from "../tokenizer";
19
19
  import type { Qwen35Model } from "../model/qwen3_5";
20
20
  import { qwenRopeIndex, type MropeRequestState } from "../model/qwen3-mrope";
21
- import { preprocessQwen3VLImage, preprocessQwen3VLVideoFrames, pyFixed1 } from "./qwen3vl-preprocess";
21
+ import { preprocessQwen3VLImage, preprocessQwen3VLVideoFrames, pyFixed1, type Qwen3VLPreprocessed } from "./qwen3vl-preprocess";
22
22
  import { extractVideoFrames } from "./video-frames";
23
23
  import { QWEN3VL_MERGE_SIZE } from "./qwen3vl-preprocess";
24
24
  import type { Qwen3VLVisionTower } from "./qwen3vl-tower";
25
+ import { createHash } from "node:crypto";
26
+ import type { EncoderCache } from "../backends/mlx/encoder-cache";
25
27
 
26
28
  export interface Qwen3VLTokenIds {
27
29
  imageTokenId: number; // <|image_pad|>
@@ -34,6 +36,7 @@ export interface Qwen3VLTokenIds {
34
36
 
35
37
  export interface Qwen3VLVisionPrompt {
36
38
  ids: number[];
39
+ prefixIdentity?: string;
37
40
  /** [1, L, H] bf16 spliced input embeddings (caller owns → generate()). */
38
41
  embeddings: MlxArray;
39
42
  /** Request mRoPE state for Qwen35Model.mrope. */
@@ -50,6 +53,8 @@ export async function buildQwen3VLVisionPrompt(
50
53
  tokenIds: Qwen3VLTokenIds,
51
54
  renderOptions: Parameters<ChatTemplate["render"]>[1] = {},
52
55
  videos: Uint8Array[] = [],
56
+ nativeWork: <T>(work: () => Promise<T>) => Promise<T> = work => work(),
57
+ encoderCache?: EncoderCache,
53
58
  ): Promise<Qwen3VLVisionPrompt> {
54
59
  // The request's full template options (tools, enableThinking,
55
60
  // reasoningEffort, preserveThinking) flow through — a media prompt must
@@ -71,9 +76,9 @@ export async function buildQwen3VLVisionPrompt(
71
76
 
72
77
  // Preprocess each medium in its own appearance order; videos decode to
73
78
  // sampled frames through the AVFoundation sidecar first.
74
- const imagePps = [];
79
+ const imagePps: Qwen3VLPreprocessed[] = [];
75
80
  for (const img of images) imagePps.push(await preprocessQwen3VLImage(img));
76
- const videoPps = [];
81
+ const videoPps: Qwen3VLPreprocessed[] = [];
77
82
  for (const vid of videos)
78
83
  videoPps.push(preprocessQwen3VLVideoFrames(await extractVideoFrames(vid)));
79
84
 
@@ -93,11 +98,13 @@ export async function buildQwen3VLVisionPrompt(
93
98
  const grids: [number, number, number][] = [];
94
99
  let imgIdx = 0;
95
100
  let vidIdx = 0;
101
+ let lastMediaEnd = 0;
96
102
  for (const t of rawIds) {
97
103
  if (t === tokenIds.imageTokenId) {
98
104
  const pp = imagePps[imgIdx++]!;
99
105
  grids.push(pp.gridThw);
100
106
  for (let i = 0; i < pp.imageTokens; i++) ids.push(tokenIds.imageTokenId);
107
+ lastMediaEnd = ids.length;
101
108
  } else if (t === tokenIds.videoTokenId) {
102
109
  const pp = videoPps[vidIdx++]!;
103
110
  const [gridT, gridH, gridW] = pp.gridThw;
@@ -111,6 +118,7 @@ export async function buildQwen3VLVisionPrompt(
111
118
  for (let i = 0; i < frameTokens; i++) ids.push(tokenIds.videoTokenId);
112
119
  ids.push(visionEndId);
113
120
  }
121
+ lastMediaEnd = ids.length;
114
122
  } else ids.push(t);
115
123
  }
116
124
 
@@ -126,71 +134,100 @@ export async function buildQwen3VLVisionPrompt(
126
134
  // path can't reach any of it (2026-08-18 review: repeated failing media
127
135
  // requests would otherwise strand un-admitted GPU memory on the GC
128
136
  // backstop).
129
- const idsArr = ops.fromInt32(ids, [1, ids.length]);
130
- const textEmb = model.embed.encode(idsArr); // [1, L, H]
131
- idsArr.dispose();
132
- const owned: MlxArray[] = [];
137
+ // Look up exact preprocessed content before entering the execution queue.
138
+ // URLs are not identities: their contents can change. Position/timestamp
139
+ // assembly above remains request-owned even when encoder features are reused.
140
+ const inputs = [...imagePps, ...videoPps];
141
+ const keys = encoderCache ? inputs.map(pp => createHash("sha256")
142
+ .update(`qwen3vl-v1:${JSON.stringify(pp.gridThw)}:${pp.rows}:${pp.cols}:`)
143
+ .update(new Uint8Array(pp.pixelValues.buffer, pp.pixelValues.byteOffset, pp.pixelValues.byteLength))
144
+ .digest("hex")) : [];
145
+ const prefixIdentity = encoderCache && lastMediaEnd > 0
146
+ ? createHash("sha256").update("qwen-prepared-prefix-v1").update(encoderCache.identity)
147
+ .update(JSON.stringify({ keys, tokens: ids.slice(0, lastMediaEnd), delta: mrope.delta,
148
+ positions: mrope.positions.map(axis => Array.from(axis.slice(0, lastMediaEnd))),
149
+ })).digest("hex") : undefined;
150
+ const reused: Array<MlxArray | null> = [];
133
151
  try {
134
- const H = textEmb.shape[2]!;
135
- const segments: MlxArray[] = [];
136
- let cursor = 0;
137
- imgIdx = 0;
138
- vidIdx = 0;
139
- // A video's tower features cover ALL its frame groups; consecutive pad
140
- // RUNS consume consecutive feature-row windows.
141
- let vidFeat: MlxArray | null = null;
142
- let vidRow = 0;
143
- for (let i = 0; i < ids.length; ) {
144
- const isImage = ids[i] === tokenIds.imageTokenId;
145
- const isVideo = ids[i] === tokenIds.videoTokenId;
146
- if (isImage || isVideo) {
147
- // Measure this contiguous pad run.
148
- let runEnd = i;
149
- while (runEnd < ids.length && ids[runEnd] === ids[i]) runEnd++;
150
- const runLen = runEnd - i;
151
- if (cursor < i) {
152
- const seg = textEmb.slice([0, cursor, 0], [1, i, H]);
152
+ for (const key of keys) reused.push(await encoderCache!.take(key));
153
+ return await nativeWork(async () => {
154
+ const encode = (index: number): MlxArray => {
155
+ const cached = reused[index];
156
+ if (cached) return cached.slice([0, 0], [...cached.shape]);
157
+ const features = tower.encode(inputs[index]!);
158
+ try { if (encoderCache) encoderCache.put(keys[index]!, features); }
159
+ catch (error) { features.dispose(); throw error; }
160
+ return features;
161
+ };
162
+ const idsArr = ops.fromInt32(ids, [1, ids.length]);
163
+ const textEmb = model.embed.encode(idsArr); // [1, L, H]
164
+ idsArr.dispose();
165
+ const owned: MlxArray[] = [];
166
+ try {
167
+ const H = textEmb.shape[2]!;
168
+ const segments: MlxArray[] = [];
169
+ let cursor = 0;
170
+ imgIdx = 0;
171
+ vidIdx = 0;
172
+ // A video's tower features cover ALL its frame groups; consecutive pad
173
+ // RUNS consume consecutive feature-row windows.
174
+ let vidFeat: MlxArray | null = null;
175
+ let vidRow = 0;
176
+ for (let i = 0; i < ids.length; ) {
177
+ const isImage = ids[i] === tokenIds.imageTokenId;
178
+ const isVideo = ids[i] === tokenIds.videoTokenId;
179
+ if (isImage || isVideo) {
180
+ // Measure this contiguous pad run.
181
+ let runEnd = i;
182
+ while (runEnd < ids.length && ids[runEnd] === ids[i]) runEnd++;
183
+ const runLen = runEnd - i;
184
+ if (cursor < i) {
185
+ const seg = textEmb.slice([0, cursor, 0], [1, i, H]);
186
+ segments.push(seg);
187
+ owned.push(seg);
188
+ }
189
+ let rows: MlxArray;
190
+ if (isImage) {
191
+ const feat = encode(imgIdx++); // [runLen, H]
192
+ owned.push(feat);
193
+ rows = feat;
194
+ } else {
195
+ if (!vidFeat) {
196
+ vidFeat = encode(imagePps.length + vidIdx); // [imageTokens, H]
197
+ owned.push(vidFeat);
198
+ vidRow = 0;
199
+ }
200
+ const sl = vidFeat.slice([vidRow, 0], [vidRow + runLen, H]);
201
+ owned.push(sl);
202
+ rows = sl;
203
+ vidRow += runLen;
204
+ if (vidRow >= videoPps[vidIdx]!.imageTokens) {
205
+ vidFeat = null;
206
+ vidIdx++;
207
+ }
208
+ }
209
+ const feat3 = ops.reshape(rows, [1, runLen, H]);
210
+ segments.push(feat3);
211
+ owned.push(feat3);
212
+ cursor = runEnd;
213
+ i = cursor;
214
+ } else i++;
215
+ }
216
+ if (cursor < ids.length) {
217
+ const seg = textEmb.slice([0, cursor, 0], [1, ids.length, H]);
153
218
  segments.push(seg);
154
219
  owned.push(seg);
155
220
  }
156
- let rows: MlxArray;
157
- if (isImage) {
158
- const feat = tower.encode(imagePps[imgIdx++]!); // [runLen, H]
159
- owned.push(feat);
160
- rows = feat;
161
- } else {
162
- if (!vidFeat) {
163
- vidFeat = tower.encode(videoPps[vidIdx]!); // [imageTokens, H]
164
- owned.push(vidFeat);
165
- vidRow = 0;
166
- }
167
- const sl = vidFeat.slice([vidRow, 0], [vidRow + runLen, H]);
168
- owned.push(sl);
169
- rows = sl;
170
- vidRow += runLen;
171
- if (vidRow >= videoPps[vidIdx]!.imageTokens) {
172
- vidFeat = null;
173
- vidIdx++;
174
- }
175
- }
176
- const feat3 = ops.reshape(rows, [1, runLen, H]);
177
- segments.push(feat3);
178
- owned.push(feat3);
179
- cursor = runEnd;
180
- i = cursor;
181
- } else i++;
182
- }
183
- if (cursor < ids.length) {
184
- const seg = textEmb.slice([0, cursor, 0], [1, ids.length, H]);
185
- segments.push(seg);
186
- owned.push(seg);
187
- }
188
- const embeddings = segments.length === 1
189
- ? ops.contiguous(segments[0]!)
190
- : ops.concatAxis(segments, 1);
191
- return { ids, embeddings, mrope };
221
+ const embeddings = segments.length === 1
222
+ ? ops.contiguous(segments[0]!)
223
+ : ops.concatAxis(segments, 1);
224
+ return { ids, embeddings, mrope, prefixIdentity };
225
+ } finally {
226
+ for (const s of owned) s.dispose();
227
+ textEmb.dispose();
228
+ }
229
+ });
192
230
  } finally {
193
- for (const s of owned) s.dispose();
194
- textEmb.dispose();
231
+ for (const features of reused) features?.dispose();
195
232
  }
196
233
  }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { ptr, read } from "bun:ffi";
20
20
  import { existsSync, readFileSync } from "node:fs";
21
+ import { tensorFingerprint } from "../model/fingerprint";
21
22
  import { MlxArray, cpuStream } from "../mlx/array";
22
23
  import { C, Dtype } from "../mlx/ffi";
23
24
  import * as ops from "../mlx/ops";
@@ -55,6 +56,15 @@ interface Block {
55
56
  }
56
57
 
57
58
  export class Qwen3VLVisionTower {
59
+ #identity: string | undefined;
60
+ readonly #identityWeights: Array<[string, MlxArray]>;
61
+
62
+ /** Computed once, under native preparation. Exact weight content prevents
63
+ * cross-artifact SSD reuse even when model aliases and shapes are identical. */
64
+ get cacheIdentity(): string {
65
+ if (this.#identity) return this.#identity;
66
+ return this.#identity = tensorFingerprint(this.#identityWeights);
67
+ }
58
68
  #patchW: MlxArray; // [1536, 1152] — flattened conv, already transposed
59
69
  #patchB: MlxArray;
60
70
  #posEmbed: MlxArray; // [2304, 1152]
@@ -64,6 +74,7 @@ export class Qwen3VLVisionTower {
64
74
  #mergerFc2W: MlxArray; #mergerFc2B: MlxArray;
65
75
 
66
76
  private constructor(w: Map<string, MlxArray>) {
77
+ this.#identityWeights = [...w];
67
78
  const t = (n: string): MlxArray => {
68
79
  const a = w.get(`vision_tower.${n}`);
69
80
  if (!a) throw new Error(`vision sidecar missing tensor vision_tower.${n}`);
@@ -54,6 +54,7 @@
54
54
  // composition order. Left at tier-a for now (good enough — grounded, exact ids,
55
55
  // greedy prefix); revisit to drive the encoder to 0% by aligning the op order.
56
56
 
57
+ import { tensorFingerprint } from "../model/fingerprint";
57
58
  import { ptr, read } from "bun:ffi";
58
59
  import { MlxArray, cpuStream } from "../mlx/array";
59
60
  import { C, Dtype } from "../mlx/ffi";
@@ -189,6 +190,13 @@ export async function preprocessSiglip(
189
190
 
190
191
  export class SiglipVisionTower {
191
192
  #weights = new Map<string, MlxArray>();
193
+ #cacheIdentity?: string;
194
+ /** Immutable encoder weights and computation settings, hashed once on use. */
195
+ get cacheIdentity(): string {
196
+ return this.#cacheIdentity ??= new Bun.CryptoHasher("sha256")
197
+ .update("gemma-siglip-v1").update(JSON.stringify([this.cfg, this.embedScale]))
198
+ .update(tensorFingerprint(this.#weights)).digest("hex");
199
+ }
192
200
  readonly cfg: SiglipVisionConfig;
193
201
  readonly embedScale: number;
194
202
  #wdtype: Dtype = Dtype.bfloat16;
package/src/web/app.html CHANGED
@@ -1118,6 +1118,13 @@
1118
1118
  #chat-attach-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
1119
1119
  border:1px solid var(--hairline);color:var(--ink);font-size:23px;line-height:1;cursor:pointer;transition:background .15s,border-color .15s}
1120
1120
  #chat-attach-btn:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
1121
+ /* hold-to-talk mic (voice.ts) */
1122
+ #chat-mic-btn{flex:0 0 auto;width:50px;height:50px;border-radius:14px;background:var(--card);
1123
+ border:1px solid var(--hairline);color:var(--ink);font-size:20px;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none;transition:background .15s,border-color .15s}
1124
+ #chat-mic-btn:hover{background:var(--card-hover);border-color:rgba(10,132,255,.5)}
1125
+ #chat-mic-btn.recording{background:rgba(255,69,58,.18);border-color:rgba(255,69,58,.7);animation:micpulse 1s ease-in-out infinite}
1126
+ #chat-mic-btn.busy{opacity:.6;cursor:progress}
1127
+ @keyframes micpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,69,58,.35)}50%{box-shadow:0 0 0 8px rgba(255,69,58,0)}}
1121
1128
  .attach-row{display:none;flex-wrap:wrap;gap:8px;max-width:840px;width:100%;margin:0 auto 8px}
1122
1129
  .attach-chip{display:flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--hairline);
1123
1130
  border-radius:10px;padding:5px 9px;font-size:12px;max-width:240px}
@@ -1496,6 +1503,11 @@
1496
1503
  <div class="inputrow">
1497
1504
  <button id="chat-attach-btn" class="iconbtn" title="Attach files or images" aria-label="Attach files">+</button>
1498
1505
  <input type="file" id="chat-file-input" multiple style="display:none">
1506
+ <!-- Hold-to-talk dictation (voice.ts): shown only when the `ready`
1507
+ frame carries transcription=true (a Whisper checkpoint serves
1508
+ /v1/audio/sessions). Press and hold (mouse/touch, or Space/Enter
1509
+ while focused); release to transcribe into the box. -->
1510
+ <button id="chat-mic-btn" class="iconbtn" title="Hold to talk (Whisper)" aria-label="Hold to talk" style="display:none">🎙</button>
1499
1511
  <div id="chat-box-wrap">
1500
1512
  <!-- Keep the placeholder SHORT: the Enter/Shift+Enter AND "#"
1501
1513
  hints live in the chat-hint status line below (and the Cmd+/
package/src/web/app.js CHANGED
@@ -1209,6 +1209,7 @@ function readText(file) {
1209
1209
  class ComposerState {
1210
1210
  attachments = [];
1211
1211
  visionCapable = false;
1212
+ transcriptionCapable = false;
1212
1213
  thinkingCapable = false;
1213
1214
  thinkingOn = true;
1214
1215
  attachSeq = 0;
@@ -1321,6 +1322,9 @@ function updateAttachHint(state) {
1321
1322
  const btn = $("chat-attach-btn");
1322
1323
  if (btn)
1323
1324
  btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
1325
+ const mic = $("chat-mic-btn");
1326
+ if (mic)
1327
+ mic.style.display = state.transcriptionCapable ? "" : "none";
1324
1328
  }
1325
1329
  function escHtml2(s) {
1326
1330
  return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
@@ -1983,6 +1987,160 @@ function renderQueue(q) {
1983
1987
  bar.style.display = parts.length ? "flex" : "none";
1984
1988
  }
1985
1989
 
1990
+ // src/web/src/voice.ts
1991
+ var SAMPLE_RATE = 16000;
1992
+ var CHUNK_SECONDS = 0.25;
1993
+ var take = null;
1994
+ var starting = false;
1995
+ async function postChunk(id, pcm) {
1996
+ const r = await fetch(`/v1/audio/sessions/${id}/audio`, {
1997
+ method: "POST",
1998
+ headers: { "content-type": "audio/pcm;rate=16000" },
1999
+ body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength))
2000
+ });
2001
+ if (!r.ok)
2002
+ throw new Error(`audio chunk rejected (${r.status})`);
2003
+ }
2004
+ function flush(t) {
2005
+ if (t.pendingLen === 0)
2006
+ return;
2007
+ const buf = new Float32Array(t.pendingLen);
2008
+ let o = 0;
2009
+ for (const p of t.pending) {
2010
+ buf.set(p, o);
2011
+ o += p.length;
2012
+ }
2013
+ t.pending = [];
2014
+ t.pendingLen = 0;
2015
+ t.sending = t.sending.then(() => postChunk(t.id, buf)).catch((e) => {
2016
+ t.failed = e.message;
2017
+ });
2018
+ }
2019
+ async function begin(btn) {
2020
+ if (take || starting)
2021
+ return;
2022
+ starting = true;
2023
+ btn.classList.add("busy");
2024
+ try {
2025
+ const r = await fetch("/v1/audio/sessions", {
2026
+ method: "POST",
2027
+ headers: { "content-type": "application/json" },
2028
+ body: JSON.stringify({ vad: true, vad_min_speech_ms: 120, temperature: 0 })
2029
+ });
2030
+ if (!r.ok)
2031
+ throw new Error(r.status === 503 ? "no Whisper model on this server" : `session failed (${r.status})`);
2032
+ const { id } = await r.json();
2033
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
2034
+ const ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
2035
+ const src = ctx.createMediaStreamSource(stream);
2036
+ const node = ctx.createScriptProcessor(4096, 1, 1);
2037
+ const t = { id, ctx, stream, node, pending: [], pendingLen: 0, sending: Promise.resolve(), failed: null };
2038
+ node.onaudioprocess = (e) => {
2039
+ if (take !== t)
2040
+ return;
2041
+ const data = e.inputBuffer.getChannelData(0);
2042
+ t.pending.push(new Float32Array(data));
2043
+ t.pendingLen += data.length;
2044
+ if (t.pendingLen >= SAMPLE_RATE * CHUNK_SECONDS)
2045
+ flush(t);
2046
+ };
2047
+ src.connect(node);
2048
+ node.connect(ctx.destination);
2049
+ take = t;
2050
+ btn.classList.add("recording");
2051
+ } catch (e) {
2052
+ toast(`Voice input failed: ${e.message}`, "err");
2053
+ } finally {
2054
+ starting = false;
2055
+ btn.classList.remove("busy");
2056
+ }
2057
+ }
2058
+ async function end(btn, box) {
2059
+ const t = take;
2060
+ if (!t)
2061
+ return;
2062
+ take = null;
2063
+ btn.classList.remove("recording");
2064
+ btn.classList.add("busy");
2065
+ try {
2066
+ t.node.disconnect();
2067
+ for (const track of t.stream.getTracks())
2068
+ track.stop();
2069
+ t.ctx.close();
2070
+ flush(t);
2071
+ await t.sending;
2072
+ if (t.failed)
2073
+ throw new Error(t.failed);
2074
+ const r = await fetch(`/v1/audio/sessions/${t.id}/finish`, { method: "POST" });
2075
+ if (!r.ok)
2076
+ throw new Error(`transcription failed (${r.status})`);
2077
+ const body = await r.json();
2078
+ const text = (body.text || "").trim();
2079
+ if (!text) {
2080
+ toast(body.mlx_bun?.vad?.speech === false ? "No speech detected" : "Nothing transcribed", "");
2081
+ return;
2082
+ }
2083
+ insertAtCaret(box, text);
2084
+ } catch (e) {
2085
+ toast(`Voice input failed: ${e.message}`, "err");
2086
+ } finally {
2087
+ btn.classList.remove("busy");
2088
+ }
2089
+ }
2090
+ function insertAtCaret(box, text) {
2091
+ const start = box.selectionStart ?? box.value.length;
2092
+ const endPos = box.selectionEnd ?? start;
2093
+ const before = box.value.slice(0, start);
2094
+ const after = box.value.slice(endPos);
2095
+ const lead = before.length && !/\s$/.test(before) ? " " : "";
2096
+ const trail = after.length && !/^\s/.test(after) ? " " : "";
2097
+ box.value = before + lead + text + trail + after;
2098
+ const caret = (before + lead + text + trail).length;
2099
+ box.setSelectionRange(caret, caret);
2100
+ box.dispatchEvent(new Event("input", { bubbles: true }));
2101
+ box.focus();
2102
+ }
2103
+ function initVoiceInput(btn, box) {
2104
+ if (!btn)
2105
+ return;
2106
+ if (!navigator.mediaDevices?.getUserMedia) {
2107
+ btn.title = "Microphone capture needs a secure context (https or localhost)";
2108
+ }
2109
+ btn.addEventListener("pointerdown", (e) => {
2110
+ e.preventDefault();
2111
+ btn.setPointerCapture(e.pointerId);
2112
+ begin(btn);
2113
+ });
2114
+ const release = () => {
2115
+ end(btn, box);
2116
+ };
2117
+ btn.addEventListener("pointerup", release);
2118
+ btn.addEventListener("pointercancel", release);
2119
+ btn.addEventListener("lostpointercapture", release);
2120
+ let keyHeld = false;
2121
+ btn.addEventListener("keydown", (e) => {
2122
+ if ((e.key === " " || e.key === "Enter") && !keyHeld) {
2123
+ e.preventDefault();
2124
+ keyHeld = true;
2125
+ begin(btn);
2126
+ }
2127
+ });
2128
+ btn.addEventListener("keyup", (e) => {
2129
+ if ((e.key === " " || e.key === "Enter") && keyHeld) {
2130
+ e.preventDefault();
2131
+ keyHeld = false;
2132
+ release();
2133
+ }
2134
+ });
2135
+ btn.addEventListener("blur", () => {
2136
+ if (keyHeld) {
2137
+ keyHeld = false;
2138
+ release();
2139
+ }
2140
+ });
2141
+ $.voice = { begin: () => begin(btn), end: () => end(btn, box) };
2142
+ }
2143
+
1986
2144
  // src/web/src/sessions.ts
1987
2145
  var SESSIONS_STYLE_ID = "mlxbun-sessions-style";
1988
2146
  function injectSessionsStyles() {
@@ -3809,6 +3967,7 @@ function createChatController() {
3809
3967
  case "ready":
3810
3968
  $("nav-model").textContent = m.model || $("nav-model").textContent || "no model";
3811
3969
  composer.visionCapable = !!m.vision;
3970
+ composer.transcriptionCapable = !!m.transcription;
3812
3971
  updateAttachHint(composer);
3813
3972
  composer.thinkingCapable = !!m.thinking;
3814
3973
  updateThinkingToggle(composer);
@@ -4026,6 +4185,7 @@ function createChatController() {
4026
4185
  });
4027
4186
  initMemoryPanel();
4028
4187
  $("chat-attach-btn").onclick = () => $("chat-file-input").click();
4188
+ initVoiceInput($("chat-mic-btn"), box);
4029
4189
  $("chat-file-input").addEventListener("change", (e) => {
4030
4190
  addFiles(composer, [...e.target.files || []]);
4031
4191
  e.target.value = "";
@@ -30,6 +30,7 @@ import {
30
30
  renderAttachments, renderContext, renderLane, renderQueue, updateAttachHint,
31
31
  updateThinkingToggle, type ContextFrame,
32
32
  } from "./composer";
33
+ import { initVoiceInput } from "./voice";
33
34
  import {
34
35
  addMsgActions, newSiblingInfo, newSidebarState, renderSessions, renderSiblingToggle,
35
36
  switchSiblingTarget, applySessSearch, type SidebarState, type SiblingInfo,
@@ -764,7 +765,7 @@ export function createChatController() {
764
765
  switch (m.type) {
765
766
  case "ready":
766
767
  $("nav-model").textContent = m.model || ($("nav-model").textContent as string) || "no model";
767
- composer.visionCapable = !!m.vision; updateAttachHint(composer);
768
+ composer.visionCapable = !!m.vision; composer.transcriptionCapable = !!m.transcription; updateAttachHint(composer);
768
769
  composer.thinkingCapable = !!m.thinking; updateThinkingToggle(composer);
769
770
  // Per-model recommended sampling (generation_config.json, server-CLI
770
771
  // overrides applied) — replaces the old hardcoded SAMP_REC shape.
@@ -979,6 +980,8 @@ export function createChatController() {
979
980
  initMemoryPanel();
980
981
  // Attachments: + button, file picker, clipboard paste, drag-and-drop.
981
982
  ($("chat-attach-btn") as HTMLButtonElement).onclick = () => ($("chat-file-input") as HTMLInputElement).click();
983
+ // Hold-to-talk mic → /v1/audio/sessions → text into the composer (voice.ts).
984
+ initVoiceInput($("chat-mic-btn") as HTMLButtonElement, box as HTMLTextAreaElement);
982
985
  $("chat-file-input").addEventListener("change", (e) => { addFiles(composer, [...(e.target as HTMLInputElement).files || []]); (e.target as HTMLInputElement).value = ""; });
983
986
  box.addEventListener("paste", (e) => {
984
987
  const items = e.clipboardData && e.clipboardData.items;
@@ -55,6 +55,7 @@ function readText(file: File): Promise<string> {
55
55
  export class ComposerState {
56
56
  attachments: Attachment[] = [];
57
57
  visionCapable = false; // from the `ready` frame; gates image attachments
58
+ transcriptionCapable = false; // from the `ready` frame; shows the hold-to-talk mic
58
59
  thinkingCapable = false; // from `ready`; gates the reasoning toggle
59
60
  // default on: with tools (read/web_search) the reasoning step gives the
60
61
  // small model far better tool judgment (answers math/writing itself,
@@ -179,6 +180,8 @@ export function buildMessageText(state: ComposerState, userText: string): BuiltM
179
180
  export function updateAttachHint(state: ComposerState): void {
180
181
  const btn = $("chat-attach-btn");
181
182
  if (btn) btn.title = state.visionCapable ? "Attach files or images" : "Attach files (this model can't see images)";
183
+ const mic = $("chat-mic-btn");
184
+ if (mic) mic.style.display = state.transcriptionCapable ? "" : "none";
182
185
  }
183
186
 
184
187
  /* ────────────────────────────────────────────────────────────────────