mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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 +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. 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";
@@ -260,7 +259,6 @@ export async function trainLora(
260
259
  // autograd flows and the backward recomputes the gelu from the primal instead
261
260
  // of retaining the spelled-out intermediates. No-op for non-Gemma models.
262
261
  // Cleared in the finally below.
263
- setFusedGeluTraining(true);
264
262
 
265
263
  const saveCfg: SaveAdapterConfig = {
266
264
  rank: cfg.rank,
@@ -383,11 +381,20 @@ export async function trainLora(
383
381
  });
384
382
  return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
385
383
  } finally {
386
- setFusedGeluTraining(false);
387
384
  disposeLora(lora);
388
385
  }
389
386
  }
390
387
 
388
+ // ---------------------------------------------------------------------------
389
+ // Segment planning: uniform planSegmentsBySize only. A full-attention
390
+ // ISOLATION planner (kernel-review backlog #2 / segmented-backward-training.md
391
+ // §5) was built and A/B-measured 2026-07-02: ZERO peak win — mlx's sdpa
392
+ // backward is O(L²) for EVERY layer (~3.5 GB/layer @8K e4b), sliding included,
393
+ // so the worst segment is set by layer count alone and segment_size is the
394
+ // whole knob (seg1 = 14.59 GB @8K, +3% step time — fits the 24 GB M4 Pro).
395
+ // Evidence: scripts/experiments/seg-isolation-smoke.ts; note in segmented.ts.
396
+ // ---------------------------------------------------------------------------
397
+
391
398
  // ---------------------------------------------------------------------------
392
399
  // SFT loop
393
400
  // ---------------------------------------------------------------------------
@@ -421,14 +428,14 @@ async function sftLoop(
421
428
  const useSegmented = cfg.segmentSize > 0;
422
429
  let segmented: SegmentedBackward | SegmentedBackwardGemma4 | null = null;
423
430
  if (useSegmented) {
424
- if (model instanceof MiniCPM5Model)
425
- segmented = new SegmentedBackward(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
426
- else if (model instanceof Gemma4Model)
427
- segmented = new SegmentedBackwardGemma4(model, lora, planSegmentsBySize(model.layers.length, cfg.segmentSize));
428
- else
431
+ if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
429
432
  throw new Error("segmented backward (segmentSize > 0) is only wired for MiniCPM5 and Gemma4");
433
+ const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
434
+ segmented = model instanceof MiniCPM5Model
435
+ ? new SegmentedBackward(model, lora, ranges)
436
+ : new SegmentedBackwardGemma4(model, lora, ranges);
430
437
  emit({ type: "stage", stage: "setup", progress: 0.04,
431
- message: `segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers` });
438
+ message: `segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers` });
432
439
  }
433
440
 
434
441
  // The value_and_grad loss closure: temporarily swap the differentiated
@@ -479,7 +486,7 @@ async function sftLoop(
479
486
  // scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
480
487
  // Cleared in finally.
481
488
  //
482
- // GEMMA GUARD (same convention as the PERF_KERNEL/FUSED_GELU sanitization in
489
+ // GEMMA GUARD (same convention as the launcher's env sanitization in
483
490
  // cli.ts / job.ts, but enforced at the trainer): e4b on this path SIGTRAPed
484
491
  // (uncatchable native crash) at multi-K sequence lengths (>=2K, reproduced in
485
492
  // scripts/experiments/segmented-grad-test-e4b.ts; docs/reference/training.md)
@@ -832,6 +839,8 @@ async function orpoLoop(
832
839
  let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
833
840
  let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
834
841
  if (useSegmented) {
842
+ if (!(model instanceof MiniCPM5Model) && !(model instanceof Gemma4Model))
843
+ throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
835
844
  const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
836
845
  if (useSegmentedPrefix) {
837
846
  // The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
@@ -845,15 +854,13 @@ async function orpoLoop(
845
854
  segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
846
855
  }
847
856
  emit({ type: "stage", stage: "setup", progress: 0.04,
848
- message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
857
+ message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${ranges.length} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
849
858
  } else if (model instanceof MiniCPM5Model)
850
859
  segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
851
- else if (model instanceof Gemma4Model)
852
- segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
853
860
  else
854
- throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
861
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
855
862
  if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
856
- message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
863
+ message: `orpo segmented backward: ${ranges.length} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
857
864
  }
858
865
 
859
866
  // Token-chunked head (non-segmented path only): the per-chunk checkpoints are
@@ -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
  }