mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -20,7 +20,6 @@ import type { RuntimeModel } from "../model/factory";
20
20
  import { Gemma4Model, type GradCheckpointCtx } from "../model/gemma4";
21
21
  import { MiniCPM5Model } from "../model/minicpm5";
22
22
  import { setTrainingAttn } from "../model/flash-attention";
23
- import { setFusedGeluTraining } from "../model/fused-geglu-kernel";
24
23
  import { SegmentedBackward, SegmentedBackwardGemma4, SegmentedBackwardOrpo, SegmentedBackwardOrpoGemma4, SegmentedBackwardOrpoPrefix, SegmentedBackwardOrpoPrefixGemma4, planSegmentsBySize } from "./segmented";
25
24
  import type { LoadedTokenizer } from "../tokenizer";
26
25
  import type { ChatTemplate } from "../chat-template";
@@ -203,6 +202,51 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
203
202
  baseModel: "",
204
203
  };
205
204
 
205
+ /** Build the per-leaf LoRA+ LR multipliers for flatParams' stable
206
+ * `[...A, ...B]` order. `undefined` keeps AdamW's uniform-LR fast path. */
207
+ export function loraPlusLrScale(
208
+ paramCount: number,
209
+ targetCount: number,
210
+ ratio: number,
211
+ ): number[] | undefined {
212
+ if (!Number.isFinite(ratio) || ratio < 1)
213
+ throw new RangeError(`loraPlusRatio must be a finite number >= 1 (got ${ratio})`);
214
+ if (paramCount !== targetCount * 2)
215
+ throw new Error(
216
+ `LoRA+ expected ${targetCount * 2} A/B leaves for ${targetCount} targets, got ${paramCount}`,
217
+ );
218
+ if (ratio === 1) return undefined;
219
+ return Array.from({ length: paramCount }, (_, i) => (i < targetCount ? 1 : ratio));
220
+ }
221
+
222
+ /** One optimizer policy for SFT, DPO, and ORPO. Keeping LoRA+ here prevents a
223
+ * method-specific loop from silently accepting the knob without applying it. */
224
+ function createLoraOptimizer(
225
+ params: MlxArray[],
226
+ lora: TrainableLora,
227
+ cfg: TrainConfig,
228
+ emit: Emit,
229
+ ): AdamW {
230
+ const lrScale = loraPlusLrScale(params.length, lora.targets.length, cfg.loraPlusRatio);
231
+ if (lrScale) emit({
232
+ type: "stage",
233
+ stage: "setup",
234
+ progress: 0.04,
235
+ message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR`,
236
+ });
237
+ return new AdamW(
238
+ params,
239
+ {
240
+ lr: cfg.learningRate,
241
+ betas: cfg.betas,
242
+ eps: 1e-8,
243
+ weightDecay: cfg.weightDecay,
244
+ lrScale,
245
+ },
246
+ (i, p) => writeParam(lora, i, p),
247
+ );
248
+ }
249
+
206
250
  /** Run a LoRA fine-tune. Emits per-step train metrics, periodic val metrics,
207
251
  * and a final stage:done with the adapter path + applied ranks. Saves the
208
252
  * adapter (last + best-on-val) and returns where it landed. */
@@ -260,7 +304,6 @@ export async function trainLora(
260
304
  // autograd flows and the backward recomputes the gelu from the primal instead
261
305
  // of retaining the spelled-out intermediates. No-op for non-Gemma models.
262
306
  // Cleared in the finally below.
263
- setFusedGeluTraining(true);
264
307
 
265
308
  const saveCfg: SaveAdapterConfig = {
266
309
  rank: cfg.rank,
@@ -288,6 +331,7 @@ export async function trainLora(
288
331
  // weak proxy. Capture them when the loop emits them so metrics.json lets you
289
332
  // pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
290
333
  const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
334
+ const checkpointSaves: Promise<void>[] = [];
291
335
  const startedAt = Date.now();
292
336
 
293
337
  // Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
@@ -321,7 +365,11 @@ export async function trainLora(
321
365
  if (cfg.saveCheckpoints) {
322
366
  const tag = `step-${String(e.step).padStart(5, "0")}-val${e.loss.toFixed(4)}`;
323
367
  checkpoint = `${cfg.adapterPath}/checkpoints/${tag}`;
324
- saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
368
+ const save = saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
369
+ checkpointSaves.push(save);
370
+ // Keep a handler attached while training continues. Promise.all below
371
+ // still surfaces the original rejection before the run can succeed.
372
+ void save.catch(() => {});
325
373
  emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
326
374
  message: `checkpoint ${tag}` });
327
375
  }
@@ -339,9 +387,11 @@ export async function trainLora(
339
387
  ? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
340
388
  : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
341
389
 
390
+ await Promise.all(checkpointSaves);
391
+
342
392
  // Final save (last adapter).
343
393
  detachTraining(model, lora);
344
- saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
394
+ await saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
345
395
 
346
396
  // Durable, structured run record alongside the adapter (only when we kept
347
397
  // checkpoints). Pick the best-val one for convenience, but ship ALL of them
@@ -383,7 +433,6 @@ export async function trainLora(
383
433
  });
384
434
  return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
385
435
  } finally {
386
- setFusedGeluTraining(false);
387
436
  disposeLora(lora);
388
437
  }
389
438
  }
@@ -418,11 +467,7 @@ async function sftLoop(
418
467
  : [];
419
468
 
420
469
  const params = flatParams(lora);
421
- const opt = new AdamW(
422
- params,
423
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
424
- (i, p) => writeParam(lora, i, p),
425
- );
470
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
426
471
 
427
472
  // Segmented backward: stream the SFT backward segment-by-segment (only one
428
473
  // segment's activations live at a time). Phase A is MiniCPM5 SFT B=1 only;
@@ -489,7 +534,7 @@ async function sftLoop(
489
534
  // scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
490
535
  // Cleared in finally.
491
536
  //
492
- // GEMMA GUARD (same convention as the PERF_KERNEL/FUSED_GELU sanitization in
537
+ // GEMMA GUARD (same convention as the launcher's env sanitization in
493
538
  // cli.ts / job.ts, but enforced at the trainer): e4b on this path SIGTRAPed
494
539
  // (uncatchable native crash) at multi-K sequence lengths (>=2K, reproduced in
495
540
  // scripts/experiments/segmented-grad-test-e4b.ts; docs/reference/training.md)
@@ -661,11 +706,7 @@ async function dpoLoop(
661
706
  : [];
662
707
 
663
708
  const params = flatParams(lora);
664
- const opt = new AdamW(
665
- params,
666
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
667
- (i, p) => writeParam(lora, i, p),
668
- );
709
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
669
710
 
670
711
  const schedule =
671
712
  cfg.dpoLrSchedule === "cosine" || cfg.dpoWarmupIters > 0
@@ -792,18 +833,7 @@ async function orpoLoop(
792
833
  : [];
793
834
 
794
835
  const params = flatParams(lora);
795
- // LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
796
- const nT = lora.targets.length;
797
- const lrScale = cfg.loraPlusRatio !== 1
798
- ? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
799
- : undefined;
800
- if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
801
- message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
802
- const opt = new AdamW(
803
- params,
804
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
805
- (i, p) => writeParam(lora, i, p),
806
- );
836
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
807
837
 
808
838
  const schedule =
809
839
  cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
@@ -1175,11 +1205,11 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
1175
1205
 
1176
1206
  /** Supervised (response) tokens across ALL rows of the batch — the count
1177
1207
  * tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
1178
- function countResponseTokens(batch: SftBatch): number {
1208
+ export function countResponseTokens(batch: SftBatch): number {
1179
1209
  let total = 0;
1180
1210
  for (let r = 0; r < batch.ids.length; r++) {
1181
1211
  const promptLen = batch.promptLens[r]!;
1182
- const len = batch.ids[r]!.length;
1212
+ const len = rowLength(batch, r);
1183
1213
  total += Math.max(0, len - Math.max(promptLen, 1));
1184
1214
  }
1185
1215
  return total;
@@ -1,10 +1,21 @@
1
- // Multimodal prompt assembly: OpenAI image_url content parts → spliced
2
- // token ids + merged input embeddings + image-token mask.
1
+ // Multimodal prompt assembly: OpenAI image/audio content parts → spliced
2
+ // token ids + merged input embeddings + attention/zeroing masks.
3
3
  //
4
- // The chat template renders one <|image|> token (258880) per image part;
5
- // we expand each into <boi> + image_token × softTokens + <eoi> (matching
6
- // optiq's frontend), embed the full sequence, then overwrite the
7
- // image-token rows with the vision tower's features.
4
+ // The chat template renders one <|image|> token (258880) per image part and
5
+ // one <|audio|> token (258881) per audio part; we expand each in document
6
+ // order — <boi> + image_token × softTokens + <eoi> (matching optiq's vision
7
+ // frontend) and <boa> + audio_token × n + <eoa> with
8
+ // n = min(ceil(duration_ms/40), 750) (processing_gemma4.py) — embed the full
9
+ // sequence, then overwrite the soft-token rows with the towers' features.
10
+ //
11
+ // Attention semantics (audio-input-plan.md §3.3, resolved vs the oracle):
12
+ // - image runs attend bidirectionally among themselves (bidirMask), BUT
13
+ // any audio token in the prompt disables the vision overlay entirely —
14
+ // mixed image+audio prompts run fully causal (bidirMask = null);
15
+ // - audio runs are strictly causal, always;
16
+ // - per-layer-input id zeroing (e2b/e4b) applies to the UNION of
17
+ // multimodal soft tokens (image | audio) — multimodalMask, decoupled
18
+ // from the bidirectional mask.
8
19
 
9
20
  import { MlxArray } from "../mlx/array";
10
21
  import { Dtype } from "../mlx/ffi";
@@ -12,6 +23,10 @@ import * as ops from "../mlx/ops";
12
23
  import type { Gemma4Model } from "../model/gemma4";
13
24
  import type { ChatTemplate, ChatMessage, ToolDefinition } from "../chat-template";
14
25
  import type { LoadedTokenizer } from "../tokenizer";
26
+ import type { AudioTower } from "../audio/conformer";
27
+ import { audioSoftTokenCount, decodeAudio } from "../audio/decode";
28
+ import { extractMelFeatures } from "../audio/features";
29
+ import { fetchMediaBytes } from "../media-fetch";
15
30
 
16
31
  /** Common contract for both vision towers (encoder-free gemma4_unified in
17
32
  * ./embedder.ts and the SigLIP encoder in ./siglip.ts): preprocess image
@@ -33,12 +48,38 @@ export interface VisionPrompt {
33
48
  imageMask: MlxArray;
34
49
  }
35
50
 
51
+ export interface MultimodalPrompt {
52
+ ids: number[];
53
+ /** Unscaled merged embeddings [1, L, hidden] — caller disposes. */
54
+ embeddings: MlxArray;
55
+ /** bool [L] image-token mask for the bidirectional attention overlay.
56
+ * null when the prompt contains ANY audio (§3.3 Q1: mixed image+audio
57
+ * runs fully causal) or no images. Caller disposes when non-null. */
58
+ bidirMask: MlxArray | null;
59
+ /** bool [L] union multimodal soft-token mask (image | audio) for
60
+ * per-layer-input id zeroing (§3.3 Q2). Caller disposes. */
61
+ multimodalMask: MlxArray;
62
+ }
63
+
36
64
  export interface VisionTokenIds {
37
65
  imageTokenId: number;
38
66
  boiTokenId: number;
39
67
  eoiTokenId: number;
40
68
  }
41
69
 
70
+ export interface AudioTokenIds {
71
+ audioTokenId: number;
72
+ boaTokenId: number;
73
+ eoaTokenId: number;
74
+ }
75
+
76
+ /** The towers a multimodal prompt may need; pass only what the request uses
77
+ * (vision-only and audio-only fall out as special cases). */
78
+ export interface MultimodalTowers<P extends { softTokens: number }> {
79
+ vision?: { tower: VisionEncoder<P>; tokenIds: VisionTokenIds };
80
+ audio?: { tower: AudioTower; tokenIds: AudioTokenIds };
81
+ }
82
+
42
83
  /** Extract image bytes from OpenAI-style content parts, rewriting the
43
84
  * parts to the template's {type:"image"} form. */
44
85
  export async function extractImages(
@@ -57,7 +98,7 @@ export async function extractImages(
57
98
  const url = (part.image_url as { url: string } | undefined)?.url
58
99
  ?? (part.image_url as unknown as string);
59
100
  if (typeof url !== "string") throw new Error("image_url part missing url");
60
- images.push(await fetchImageBytes(url));
101
+ images.push(await fetchMediaBytes(url, "image"));
61
102
  parts.push({ type: "image" });
62
103
  } else if (part.type === "image") {
63
104
  // already template-form: expect base64 `data` or pass-through marker
@@ -76,83 +117,211 @@ export async function extractImages(
76
117
  return { messages: out, images };
77
118
  }
78
119
 
79
- async function fetchImageBytes(url: string): Promise<Uint8Array> {
80
- if (url.startsWith("data:")) {
81
- const comma = url.indexOf(",");
82
- if (comma === -1) throw new Error("malformed data: URL");
83
- const meta = url.slice(0, comma);
84
- const body = url.slice(comma + 1);
85
- if (!meta.includes("base64")) throw new Error("data: URL must be base64");
86
- return Uint8Array.from(Buffer.from(body, "base64"));
87
- }
88
- if (url.startsWith("http://") || url.startsWith("https://")) {
89
- const res = await fetch(url);
90
- if (!res.ok) throw new Error(`image fetch failed: ${res.status} ${url}`);
91
- return new Uint8Array(await res.arrayBuffer());
120
+ /** Extract audio bytes from OpenAI-style content parts, rewriting the parts
121
+ * to the template's {type:"audio"} form. Accepts the OpenAI-canonical
122
+ * {type:"input_audio", input_audio:{data:<b64>, format:...}} plus optiq's
123
+ * {type:"audio"} (base64 `data`) and {type:"audio_url"} aliases. */
124
+ export async function extractAudio(
125
+ messages: ChatMessage[],
126
+ ): Promise<{ messages: ChatMessage[]; audio: Uint8Array[] }> {
127
+ const audio: Uint8Array[] = [];
128
+ const out: ChatMessage[] = [];
129
+ for (const m of messages) {
130
+ if (!Array.isArray(m.content)) {
131
+ out.push(m);
132
+ continue;
133
+ }
134
+ const parts: Array<Record<string, unknown>> = [];
135
+ for (const part of m.content) {
136
+ if (part.type === "input_audio") {
137
+ const data = (part.input_audio as { data?: string } | undefined)?.data;
138
+ if (typeof data !== "string")
139
+ throw new Error("input_audio part requires base64 `input_audio.data`");
140
+ audio.push(Uint8Array.from(Buffer.from(data, "base64")));
141
+ parts.push({ type: "audio" });
142
+ } else if (part.type === "audio") {
143
+ if (typeof part.data === "string") {
144
+ audio.push(Uint8Array.from(Buffer.from(part.data, "base64")));
145
+ parts.push({ type: "audio" });
146
+ } else {
147
+ throw new Error("audio part requires base64 `data`");
148
+ }
149
+ } else if (part.type === "audio_url") {
150
+ const url = (part.audio_url as { url: string } | undefined)?.url
151
+ ?? (part.audio_url as unknown as string);
152
+ if (typeof url !== "string") throw new Error("audio_url part missing url");
153
+ audio.push(await fetchMediaBytes(url, "audio"));
154
+ parts.push({ type: "audio" });
155
+ } else {
156
+ parts.push(part);
157
+ }
158
+ }
159
+ out.push({ ...m, content: parts });
92
160
  }
93
- throw new Error(`unsupported image url scheme: ${url.slice(0, 16)}`);
161
+ return { messages: out, audio };
94
162
  }
95
163
 
96
- export async function buildVisionPrompt<P extends { softTokens: number }>(
164
+ /** Decoded + featurized audio clip, ready to splice. */
165
+ interface PreparedAudio {
166
+ mel: Float32Array;
167
+ frames: number;
168
+ softTokens: number;
169
+ }
170
+
171
+ export async function buildMultimodalPrompt<P extends { softTokens: number }>(
97
172
  model: Gemma4Model,
98
- tower: VisionEncoder<P>,
173
+ towers: MultimodalTowers<P>,
99
174
  tokenizer: LoadedTokenizer,
100
175
  template: ChatTemplate,
101
176
  messages: ChatMessage[],
102
177
  images: Uint8Array[],
103
- tokenIds: VisionTokenIds,
178
+ audio: Uint8Array[],
104
179
  tools: ToolDefinition[] | null = null,
105
- ): Promise<VisionPrompt> {
180
+ ): Promise<MultimodalPrompt> {
181
+ if (images.length > 0 && !towers.vision)
182
+ throw new Error("images present but no vision tower configured");
183
+ if (audio.length > 0 && !towers.audio)
184
+ throw new Error("audio present but no audio tower configured");
185
+
106
186
  const rendered = template.render(messages, { tools });
107
187
  let ids = tokenizer.encode(rendered);
108
188
  if (ids[0] === ids[1] && ids[0] === tokenizer.bosTokenId) ids = ids.slice(1);
109
189
 
110
- // preprocess + embed every image (in order of appearance), via the
111
- // tower's own preprocessing (encoder-free vs SigLIP differ here).
112
- const pre = await Promise.all(images.map((bytes) => tower.preprocess(bytes)));
190
+ // preprocess every clip/image up front (splice lengths depend on them);
191
+ // audio soft-token counts come from the DECODED samples, never metadata.
192
+ const pre = await Promise.all(
193
+ images.map((bytes) => towers.vision!.tower.preprocess(bytes)),
194
+ );
195
+ const clips: PreparedAudio[] = audio.map((bytes) => {
196
+ const samples = decodeAudio(bytes);
197
+ const softTokens = audioSoftTokenCount(samples.length);
198
+ const m = extractMelFeatures(samples);
199
+ return { mel: m.features, frames: m.frames, softTokens };
200
+ });
201
+
202
+ const vIds = towers.vision?.tokenIds;
203
+ const aIds = towers.audio?.tokenIds;
113
204
 
114
- // expand each <|image|> into <boi> + image_token×soft + <eoi>
205
+ // expand each <|image|> into <boi> + image_token×soft + <eoi> and each
206
+ // <|audio|> into <boa> + audio_token×n + <eoa>, in document order
115
207
  const spliced: number[] = [];
116
- const runs: { start: number; length: number }[] = [];
208
+ const runs: { start: number; length: number; kind: "image" | "audio"; index: number }[] = [];
117
209
  let imgIdx = 0;
210
+ let audIdx = 0;
118
211
  for (const id of ids) {
119
- if (id === tokenIds.imageTokenId) {
212
+ if (vIds && id === vIds.imageTokenId) {
120
213
  const p = pre[imgIdx];
121
214
  if (!p) throw new Error("more <|image|> markers than images");
122
- spliced.push(tokenIds.boiTokenId);
123
- runs.push({ start: spliced.length, length: p.softTokens });
124
- for (let k = 0; k < p.softTokens; k++) spliced.push(tokenIds.imageTokenId);
125
- spliced.push(tokenIds.eoiTokenId);
215
+ spliced.push(vIds.boiTokenId);
216
+ runs.push({ start: spliced.length, length: p.softTokens, kind: "image", index: imgIdx });
217
+ for (let k = 0; k < p.softTokens; k++) spliced.push(vIds.imageTokenId);
218
+ spliced.push(vIds.eoiTokenId);
126
219
  imgIdx++;
220
+ } else if (aIds && id === aIds.audioTokenId) {
221
+ const clip = clips[audIdx];
222
+ if (!clip) throw new Error("more <|audio|> markers than audio clips");
223
+ spliced.push(aIds.boaTokenId);
224
+ runs.push({ start: spliced.length, length: clip.softTokens, kind: "audio", index: audIdx });
225
+ for (let k = 0; k < clip.softTokens; k++) spliced.push(aIds.audioTokenId);
226
+ spliced.push(aIds.eoaTokenId);
227
+ audIdx++;
127
228
  } else {
128
229
  spliced.push(id);
129
230
  }
130
231
  }
131
232
  if (imgIdx !== images.length)
132
233
  throw new Error(`${images.length} images but ${imgIdx} <|image|> markers`);
234
+ if (audIdx !== audio.length)
235
+ throw new Error(`${audio.length} audio clips but ${audIdx} <|audio|> markers`);
133
236
 
134
- // embed text tokens, then overwrite image-token rows with vision features
237
+ // embed text tokens, then overwrite soft-token rows with tower features
135
238
  const idsArr = ops.fromInt32(spliced, [1, spliced.length]);
136
239
  let embeds = model.embed.encode(idsArr);
137
240
  idsArr.dispose();
138
241
  const hidden = embeds.shape[2]!;
139
- for (let i = 0; i < runs.length; i++) {
140
- const { start, length } = runs[i]!;
141
- const feats = tower.features(pre[i]!); // [1, soft, hidden]
142
- const cast = feats.astype(embeds.dtype);
143
- feats.dispose();
242
+ for (const run of runs) {
243
+ let cast: MlxArray;
244
+ if (run.kind === "image") {
245
+ // vision towers return pre-divided by embed_scale (their oracle's
246
+ // frontend convention) — cast to the embedding dtype and splice.
247
+ const feats = towers.vision!.tower.features(pre[run.index]!); // [1, soft, hidden]
248
+ cast = feats.astype(embeds.dtype);
249
+ feats.dispose();
250
+ } else {
251
+ // audio mirrors gen-e4b-audio-golden.py EXACTLY:
252
+ // features.astype(embeds.dtype) / embed_scale
253
+ // — raw f32 embed_audio output, cast to bf16 FIRST, then divided by a
254
+ // weak (dtype-following, i.e. bf16) embed_scale scalar. The order is
255
+ // load-bearing for the bit-exact greedy gate (see AudioTower.features).
256
+ const clip = clips[run.index]!;
257
+ const raw = towers.audio!.tower.features(clip.mel, clip.frames, false); // [1, n, hidden] f32
258
+ if (raw.shape[1] !== clip.softTokens) {
259
+ const n = raw.shape[1];
260
+ raw.dispose();
261
+ embeds.dispose();
262
+ throw new Error(`audio tower produced ${n} frames but splice expects ${clip.softTokens}`);
263
+ }
264
+ const bf = raw.astype(embeds.dtype);
265
+ raw.dispose();
266
+ const scale = ops.scalarLike(towers.audio!.tower.embedScale, bf);
267
+ cast = ops.div(bf, scale);
268
+ bf.dispose();
269
+ scale.dispose();
270
+ }
271
+ const { start, length } = run;
144
272
  const updated = ops.sliceUpdate(embeds, cast, [0, start, 0], [1, start + length, hidden]);
145
273
  cast.dispose();
146
274
  embeds.dispose();
147
275
  embeds = updated;
148
276
  }
149
277
 
150
- const maskInts = new Int32Array(spliced.length);
151
- for (let i = 0; i < spliced.length; i++)
152
- maskInts[i] = spliced[i] === tokenIds.imageTokenId ? 1 : 0;
153
- const maskI32 = MlxArray.fromInt32(maskInts, [spliced.length]);
154
- const imageMask = maskI32.astype(Dtype.bool);
155
- maskI32.dispose();
278
+ // masks over the spliced sequence: union multimodal soft tokens (per-layer
279
+ // id zeroing) always; image-only bidirectional overlay only when NO audio
280
+ // is present (§3.3 Q1).
281
+ const mmInts = new Int32Array(spliced.length);
282
+ for (let i = 0; i < spliced.length; i++) {
283
+ const id = spliced[i]!;
284
+ mmInts[i] = (vIds && id === vIds.imageTokenId) || (aIds && id === aIds.audioTokenId) ? 1 : 0;
285
+ }
286
+ const mmI32 = MlxArray.fromInt32(mmInts, [spliced.length]);
287
+ const multimodalMask = mmI32.astype(Dtype.bool);
288
+ mmI32.dispose();
156
289
 
157
- return { ids: spliced, embeddings: embeds, imageMask };
290
+ let bidirMask: MlxArray | null = null;
291
+ if (images.length > 0 && audio.length === 0) {
292
+ const imgInts = new Int32Array(spliced.length);
293
+ for (let i = 0; i < spliced.length; i++)
294
+ imgInts[i] = spliced[i] === vIds!.imageTokenId ? 1 : 0;
295
+ const imgI32 = MlxArray.fromInt32(imgInts, [spliced.length]);
296
+ bidirMask = imgI32.astype(Dtype.bool);
297
+ imgI32.dispose();
298
+ }
299
+
300
+ return { ids: spliced, embeddings: embeds, bidirMask, multimodalMask };
301
+ }
302
+
303
+ /** Vision-only prompt (back-compat wrapper over buildMultimodalPrompt —
304
+ * behavior byte-identical to the pre-audio builder: the returned imageMask
305
+ * serves as BOTH the bidirectional-attention mask and the per-layer-input
306
+ * zeroing mask, which forwardEmbeddings derives from `bidir` when no
307
+ * separate multimodal mask is passed). */
308
+ export async function buildVisionPrompt<P extends { softTokens: number }>(
309
+ model: Gemma4Model,
310
+ tower: VisionEncoder<P>,
311
+ tokenizer: LoadedTokenizer,
312
+ template: ChatTemplate,
313
+ messages: ChatMessage[],
314
+ images: Uint8Array[],
315
+ tokenIds: VisionTokenIds,
316
+ tools: ToolDefinition[] | null = null,
317
+ ): Promise<VisionPrompt> {
318
+ const mp = await buildMultimodalPrompt(
319
+ model, { vision: { tower, tokenIds } }, tokenizer, template,
320
+ messages, images, [], tools,
321
+ );
322
+ // no audio was passed, so the union mask IS the image mask — reuse it for
323
+ // the zero-image edge (the original builder returned an all-false mask).
324
+ const imageMask = mp.bidirMask ?? mp.multimodalMask;
325
+ if (mp.bidirMask) mp.multimodalMask.dispose();
326
+ return { ids: mp.ids, embeddings: mp.embeddings, imageMask };
158
327
  }