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
@@ -0,0 +1,133 @@
1
+ // WAV decode for audio input (A1 of docs/design/audio-input-plan.md).
2
+ //
3
+ // Scope: RIFF/WAVE containers — PCM 16/24/32-bit and IEEE float32, any
4
+ // channel count (mean mixdown), any sample rate (linear resample to 16 kHz).
5
+ // PCM16 scales by 1/32768 to match the oracle golden generator
6
+ // (scripts/gen-e4b-audio-golden.py) exactly. Non-WAV containers (mp3/m4a/
7
+ // flac/ogg) are transcoded to WAV by the serve layer via macOS `afconvert`
8
+ // before reaching this parser.
9
+ //
10
+ // Resampling note: optiq's serve frontend never exposes audio, so there is
11
+ // no oracle behavior for non-16 kHz input — linear interpolation is our
12
+ // documented choice (the parity fixtures are native 16 kHz and skip it).
13
+
14
+ export const AUDIO_SAMPLE_RATE = 16_000;
15
+ export const AUDIO_MS_PER_TOKEN = 40;
16
+ export const AUDIO_SEQ_LENGTH = 750; // 30 s cap, matches the oracle processor
17
+ /** Raw-speech truncation bound — the oracle feature extractor's default
18
+ * (audio_feature_extractor.py __call__: truncation=True,
19
+ * max_length=480_000 → `w[:max_length]`, i.e. 30 s at 16 kHz) applied
20
+ * BEFORE feature extraction. Mirrored in decodeAudio so a >30 s clip
21
+ * yields exactly the capped 750 soft tokens AND the matching mel frame
22
+ * count — the prompt builder hard-checks that the tower's output length
23
+ * equals the splice's soft-token count. */
24
+ export const AUDIO_MAX_SAMPLES = 30 * AUDIO_SAMPLE_RATE; // 480_000
25
+
26
+ export interface DecodedWav {
27
+ samples: Float32Array; // mono
28
+ sampleRate: number;
29
+ }
30
+
31
+ /** Number of audio soft tokens for a clip — processing_gemma4.py
32
+ * `_compute_audio_num_tokens`: ceil(duration_ms / 40) capped at 750.
33
+ * MUST be computed from decoded samples, never from container metadata. */
34
+ export function audioSoftTokenCount(
35
+ numSamples: number,
36
+ sampleRate: number = AUDIO_SAMPLE_RATE,
37
+ ): number {
38
+ const durationMs = (numSamples / sampleRate) * 1000;
39
+ return Math.min(Math.ceil(durationMs / AUDIO_MS_PER_TOKEN), AUDIO_SEQ_LENGTH);
40
+ }
41
+
42
+ const WAVE_FORMAT_PCM = 1;
43
+ const WAVE_FORMAT_IEEE_FLOAT = 3;
44
+ const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
45
+
46
+ /** Parse a RIFF/WAVE file into mono float samples at its native rate. */
47
+ export function decodeWav(bytes: Uint8Array): DecodedWav {
48
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
49
+ const tag = (o: number) => String.fromCharCode(bytes[o]!, bytes[o + 1]!, bytes[o + 2]!, bytes[o + 3]!);
50
+ if (bytes.length < 44 || tag(0) !== "RIFF" || tag(8) !== "WAVE")
51
+ throw new Error("not a RIFF/WAVE file");
52
+
53
+ let fmt: { format: number; channels: number; sampleRate: number; bits: number } | null = null;
54
+ let dataOff = -1;
55
+ let dataLen = 0;
56
+ for (let o = 12; o + 8 <= bytes.length; ) {
57
+ const id = tag(o);
58
+ const size = v.getUint32(o + 4, true);
59
+ const body = o + 8;
60
+ if (id === "fmt ") {
61
+ // the reads below reach body+16 (base fields) / body+26 (extensible)
62
+ if (size < 16 || body + 16 > bytes.length)
63
+ throw new Error("WAV: truncated fmt chunk");
64
+ let format = v.getUint16(body, true);
65
+ const channels = v.getUint16(body + 2, true);
66
+ const sampleRate = v.getUint32(body + 4, true);
67
+ const bits = v.getUint16(body + 14, true);
68
+ if (format === WAVE_FORMAT_EXTENSIBLE) {
69
+ if (size < 40 || body + 26 > bytes.length)
70
+ throw new Error("WAV: truncated WAVE_FORMAT_EXTENSIBLE fmt chunk");
71
+ format = v.getUint16(body + 24, true); // first 2 bytes of SubFormat GUID
72
+ }
73
+ fmt = { format, channels, sampleRate, bits };
74
+ } else if (id === "data") {
75
+ dataOff = body;
76
+ dataLen = Math.min(size, bytes.length - body);
77
+ }
78
+ o = body + size + (size & 1); // chunks are word-aligned
79
+ }
80
+ if (!fmt) throw new Error("WAV: missing fmt chunk");
81
+ if (dataOff < 0) throw new Error("WAV: missing data chunk");
82
+ const { format, channels, sampleRate, bits } = fmt;
83
+ if (channels < 1) throw new Error("WAV: zero channels");
84
+
85
+ const bytesPer = bits / 8;
86
+ const frames = Math.floor(dataLen / (bytesPer * channels));
87
+ const out = new Float32Array(frames);
88
+
89
+ const readSample = (off: number): number => {
90
+ if (format === WAVE_FORMAT_IEEE_FLOAT && bits === 32) return v.getFloat32(off, true);
91
+ if (format === WAVE_FORMAT_PCM && bits === 16) return v.getInt16(off, true) / 32768;
92
+ if (format === WAVE_FORMAT_PCM && bits === 24) {
93
+ const raw = bytes[off]! | (bytes[off + 1]! << 8) | (bytes[off + 2]! << 16);
94
+ return ((raw << 8) >> 8) / 8388608;
95
+ }
96
+ if (format === WAVE_FORMAT_PCM && bits === 32) return v.getInt32(off, true) / 2147483648;
97
+ throw new Error(`WAV: unsupported format ${format} / ${bits}-bit`);
98
+ };
99
+
100
+ for (let f = 0; f < frames; f++) {
101
+ const base = dataOff + f * bytesPer * channels;
102
+ let acc = 0;
103
+ for (let c = 0; c < channels; c++) acc += readSample(base + c * bytesPer);
104
+ out[f] = Math.fround(acc / channels);
105
+ }
106
+ return { samples: out, sampleRate };
107
+ }
108
+
109
+ /** Linear resample to the model rate (no-op passthrough at 16 kHz). */
110
+ export function resampleTo16k(samples: Float32Array, sampleRate: number): Float32Array {
111
+ if (sampleRate === AUDIO_SAMPLE_RATE) return samples;
112
+ if (sampleRate <= 0) throw new Error(`bad sample rate ${sampleRate}`);
113
+ const outLen = Math.max(1, Math.round((samples.length * AUDIO_SAMPLE_RATE) / sampleRate));
114
+ const out = new Float32Array(outLen);
115
+ const step = samples.length / outLen;
116
+ for (let i = 0; i < outLen; i++) {
117
+ const pos = i * step;
118
+ const j = Math.floor(pos);
119
+ const fracPart = pos - j;
120
+ const a = samples[Math.min(j, samples.length - 1)]!;
121
+ const b = samples[Math.min(j + 1, samples.length - 1)]!;
122
+ out[i] = Math.fround(a + (b - a) * fracPart);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** WAV bytes → mono 16 kHz float samples (the feature-extractor input),
128
+ * truncated at 30 s exactly like the oracle (AUDIO_MAX_SAMPLES). */
129
+ export function decodeAudio(bytes: Uint8Array): Float32Array {
130
+ const { samples, sampleRate } = decodeWav(bytes);
131
+ const s = resampleTo16k(samples, sampleRate);
132
+ return s.length > AUDIO_MAX_SAMPLES ? s.subarray(0, AUDIO_MAX_SAMPLES) : s;
133
+ }
@@ -0,0 +1,202 @@
1
+ // USM log-mel feature extraction for gemma-4 audio — a verbatim-semantics
2
+ // port of the oracle's Gemma4AudioFeatureExtractor (optiq/vlm/_mlxvlm/
3
+ // models/gemma4/audio_feature_extractor.py, constructor DEFAULTS — §3.3 Q3
4
+ // of docs/design/audio-input-plan.md: no per-bin normalization, params are
5
+ // fixed for all gemma-4 models). Gated bit-close against the T0 mel goldens
6
+ // (goldens/e4b-audio-*-mel.bin) in tests/audio-features.test.ts.
7
+ //
8
+ // Pipeline (single waveform, 16 kHz mono float32):
9
+ // pad to a multiple of 128 samples (validity mask over the original part)
10
+ // → semicausal left-pad of frame_length/2 = 160 zeros
11
+ // → unfold into 321-sample frames at hop 160, drop the 321st sample
12
+ // (preemphasis = 0 branch)
13
+ // → × periodic Hann window (numpy computes it in FLOAT32 — emulated with
14
+ // Math.fround so the products match the oracle's f32 rounding)
15
+ // → rfft n=512 (numpy promotes to float64; we FFT in f64)
16
+ // → |·| → @ mel filter bank (128 HTK bins over 0–8 kHz, float64, the
17
+ // transformers mel_filter_bank construction: linspace fft freqs,
18
+ // unguarded slope division, no norm)
19
+ // → log(x + 1e-3) → zero out invalid frames → float32 [T, 128].
20
+ //
21
+ // Frame validity: frame f is valid iff original-sample index
22
+ // f·hop + frame_length lands inside the un-padded waveform.
23
+
24
+ const SR = 16_000;
25
+ const FRAME = 320; // 20 ms
26
+ const HOP = 160; // 10 ms
27
+ const FFT_N = 512; // 2^ceil(log2(320)), fft_overdrive=false
28
+ const FREQ_BINS = FFT_N / 2 + 1; // 257
29
+ const MEL_BINS = 128;
30
+ const MEL_FMIN = 0.0;
31
+ const MEL_FMAX = 8000.0;
32
+ const MEL_FLOOR = 1e-3;
33
+ const PAD_MULTIPLE = 128;
34
+
35
+ export interface MelFeatures {
36
+ /** [frames, 128] row-major log-mel, float32 (invalid frames zeroed). */
37
+ features: Float32Array;
38
+ frames: number;
39
+ /** Per-frame validity (1 = real audio, 0 = padding). */
40
+ mask: Uint8Array;
41
+ }
42
+
43
+ // --- mel filter bank (float64, transformers semantics) -----------------------
44
+ const hzToMel = (f: number): number => 2595.0 * Math.log10(1.0 + f / 700.0);
45
+ const melToHz = (m: number): number => 700.0 * (10 ** (m / 2595.0) - 1.0);
46
+
47
+ function buildMelFilters(): Float64Array {
48
+ // filter_freqs: 130 points linspace in mel space, mapped back to Hz
49
+ const melMin = hzToMel(MEL_FMIN);
50
+ const melMax = hzToMel(MEL_FMAX);
51
+ const nPts = MEL_BINS + 2;
52
+ const filterFreqs = new Float64Array(nPts);
53
+ for (let i = 0; i < nPts; i++)
54
+ filterFreqs[i] = melToHz(melMin + ((melMax - melMin) * i) / (nPts - 1));
55
+ // fft_freqs: linspace(0, sr//2, 257)
56
+ const fftFreqs = new Float64Array(FREQ_BINS);
57
+ for (let k = 0; k < FREQ_BINS; k++) fftFreqs[k] = (8000.0 * k) / (FREQ_BINS - 1);
58
+ // triangular filters: max(0, min(down, up)) — unguarded diffs like the oracle
59
+ const filters = new Float64Array(FREQ_BINS * MEL_BINS);
60
+ for (let m = 0; m < MEL_BINS; m++) {
61
+ const lower = filterFreqs[m]!;
62
+ const center = filterFreqs[m + 1]!;
63
+ const upper = filterFreqs[m + 2]!;
64
+ for (let k = 0; k < FREQ_BINS; k++) {
65
+ const down = (fftFreqs[k]! - lower) / (center - lower);
66
+ const up = (upper - fftFreqs[k]!) / (upper - center);
67
+ filters[k * MEL_BINS + m] = Math.max(0, Math.min(down, up));
68
+ }
69
+ }
70
+ return filters;
71
+ }
72
+
73
+ // --- periodic Hann window: the ORACLE'S EXACT float32 values -----------------
74
+ // numpy builds this in float32 end-to-end (arange f32 → ×2π/320 → cos, all in
75
+ // numpy's vectorized f32 routines) and those cos values differ from
76
+ // fround(Math.cos(x)) by 1 ulp on some inputs. That 1 ulp is the ONLY
77
+ // divergence in the whole pipeline: with these exact values our extractor
78
+ // reproduces the oracle mel golden BIT-EXACTLY; with a recomputed window the
79
+ // log-amplified cancellation error reaches ~4.5e-4 (verified 2026-07-07).
80
+ // So the constant IS the spec. Regen (oracle venv):
81
+ // python -c "from optiq.vlm._mlxvlm.models.gemma4.audio_feature_extractor \
82
+ // import Gemma4AudioFeatureExtractor as F; import base64; \
83
+ // print(base64.b64encode(F().window.astype('<f4').tobytes()).decode())"
84
+ const WINDOW_B64 =
85
+ "AAAAAAAgyjgAHMo5AFRjOgAHyjqAyR07ACJjOwCDmjvAtsk7wCf/O0BoHTyAVT48gFhiPNC2hDyQ" +
86
+ "yJk8UF+wPPB4yDwQE+I8ECv9PDDfDD0Q5Rs9qKUrPXAfPD3IUE09+DdfPUjTcT1okII9XI+MPYDl" +
87
+ "lj3QkaE9PJOsPbDotz0MkcM9LIvPPeDV2z30b+g9JFj1PZZGAT7iBgg+SOwOPhr2FT6mIx0+PHQk" +
88
+ "PhrnKz6OezM+zjA7Ph4GQz64+ko+0A1TPp4+Wz5OjGM+EvZrPhR7dD54Gn0+uemCPopShz5Ix4s+" +
89
+ "fEeQPrbSlD6GaJk+dQiePhCyoj7gZKc+biCsPkTksD7or7U+4oK6Prdcvz7uPMQ+CiPJPpEOzj4F" +
90
+ "/9I+6fPXPsTs3D4S6eE+W+jmPhvq6z7Y7fA+EvP1Pkr5+j4AAAA/XIMCP3cGBT8ViQc/8woKP9OL" +
91
+ "DD93Cw8/oIkRPwwGFD9+gBY/uPgYP3xuGz+K4R0/pVEgP5C+Ij8OKCU/340nP8rvKT+RTSw/+KYu" +
92
+ "P8b7MD++SzM/pZY1P0PcNz9dHDo/u1Y8PySLPj9iuUA/PeFCP3wCRT/sHEc/WjBJP448Sz9SQU0/" +
93
+ "eD5PP84zUT8dIVM/OgZVP/LiVj8Yt1g/eoJaP+5EXD9I/l0/Wq5fP/xUYT8C8mI/RIVkP5oOZj/e" +
94
+ "jWc/6gJpP5ltaj/GzWs/UCNtPxVubj/0rW8/zOJwP4EMcj/0KnM/Cj50P6ZFdT+wQXY/DTJ3P6gW" +
95
+ "eD9o73g/OLx5PwZ9ej+8MXs/Stp7P552fD+qBn0/YIp9P7ABfj+SbH4/+sp+P94cfz82Yn8//Zp/" +
96
+ "PyvHfz+85n8/r/l/PwAAgD+v+X8/vOZ/PyvHfz/9mn8/NmJ/P94cfz/6yn4/kmx+P7ABfj9gin0/" +
97
+ "qgZ9P552fD9J2ns/uzF7PwV9ej84vHk/aO94P6gWeD8NMnc/r0F2P6RFdT8IPnQ/8ipzP4AMcj/M" +
98
+ "4nA/861vPxRubj9QI20/xc1rP5dtaj/rAmk/3o1nP5oOZj9EhWQ/AfJiP/tUYT9Yrl8/SP5dP+9E" +
99
+ "XD95glo/FbdYP/DiVj84BlU/HCFTP80zUT93Pk8/UkFNP4o8Sz9ZMEk/6xxHP3gCRT894UI/YrlA" +
100
+ "PyKLPj+6Vjw/Whw6P0LcNz+mljU/vEszP8b7MD/4pi4/jk0sP8jvKT/cjSc/DCglP5C+Ij+kUSA/" +
101
+ "iuEdP3puGz+1+Bg/fYAWPwwGFD+diRE/dwsPP9KLDD/zCgo/EokHP3MGBT9dgwI/AAAAP0X5+j4R" +
102
+ "8/U+0+3wPhrq6z5U6OY+EenhPsXs3D7o89c+/v7SPo4Ozj4EI8k+6zzEPrhcvz7fgro+6a+1PkHk" +
103
+ "sD5oIKw+3WSnPgqyoj5zCJ4+iGiZPrTSlD58R5A+RMeLPoRShz616YI+ehp9Pgx7dD4Q9ms+Roxj" +
104
+ "Ppw+Wz7IDVM+qvpKPiQGQz7MMDs+hHszPhrnKz4ydCQ+piMdPhL2FT5E7A4+5AYIPpRGAT4UWPU9" +
105
+ "7G/oPdDV2z0oi889EJHDPajotz08k6w9yJGhPXDllj1Uj4w9WJCCPTjTcT34N189uFBNPXAfPD2Y" +
106
+ "pSs98OQbPTjfDD0QK/088BLiPPB4yDxAX7A8gMiZPMC2hDxgWGI8gFU+PCBoHTyAJ/87wLbJO8CC" +
107
+ "mjsAImM7gMkdOwAGyjoAVGM6ABzKOQAgyjg=";
108
+
109
+ function buildWindow(): Float32Array {
110
+ const raw = Buffer.from(WINDOW_B64, "base64");
111
+ return new Float32Array(raw.buffer, raw.byteOffset, FRAME);
112
+ }
113
+
114
+ // --- iterative radix-2 complex FFT (float64), N = 512 ------------------------
115
+ const FFT_COS = new Float64Array(FFT_N / 2);
116
+ const FFT_SIN = new Float64Array(FFT_N / 2);
117
+ for (let i = 0; i < FFT_N / 2; i++) {
118
+ FFT_COS[i] = Math.cos((-2 * Math.PI * i) / FFT_N);
119
+ FFT_SIN[i] = Math.sin((-2 * Math.PI * i) / FFT_N);
120
+ }
121
+
122
+ function fft512(re: Float64Array, im: Float64Array): void {
123
+ // bit-reversal permutation
124
+ for (let i = 1, j = 0; i < FFT_N; i++) {
125
+ let bit = FFT_N >> 1;
126
+ for (; j & bit; bit >>= 1) j ^= bit;
127
+ j ^= bit;
128
+ if (i < j) {
129
+ const tr = re[i]!; re[i] = re[j]!; re[j] = tr;
130
+ const ti = im[i]!; im[i] = im[j]!; im[j] = ti;
131
+ }
132
+ }
133
+ for (let len = 2; len <= FFT_N; len <<= 1) {
134
+ const half = len >> 1;
135
+ const step = FFT_N / len;
136
+ for (let i = 0; i < FFT_N; i += len) {
137
+ for (let k = 0; k < half; k++) {
138
+ const c = FFT_COS[k * step]!;
139
+ const s = FFT_SIN[k * step]!;
140
+ const xr = re[i + k + half]! * c - im[i + k + half]! * s;
141
+ const xi = re[i + k + half]! * s + im[i + k + half]! * c;
142
+ re[i + k + half] = re[i + k]! - xr;
143
+ im[i + k + half] = im[i + k]! - xi;
144
+ re[i + k] = re[i + k]! + xr;
145
+ im[i + k] = im[i + k]! + xi;
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ let melFilters: Float64Array | null = null;
152
+ let hannWindow: Float32Array | null = null;
153
+
154
+ /** 16 kHz mono float32 waveform → USM log-mel features [T, 128]. */
155
+ export function extractMelFeatures(waveform: Float32Array): MelFeatures {
156
+ melFilters ??= buildMelFilters();
157
+ hannWindow ??= buildWindow();
158
+
159
+ const nReal = waveform.length;
160
+ const nPadded = Math.ceil(nReal / PAD_MULTIPLE) * PAD_MULTIPLE;
161
+ const PAD_LEFT = FRAME >> 1; // 160
162
+ const total = PAD_LEFT + nPadded;
163
+ const frames = Math.floor((total - (FRAME + 1)) / HOP) + 1;
164
+ if (frames <= 0) return { features: new Float32Array(0), frames: 0, mask: new Uint8Array(0) };
165
+
166
+ const features = new Float32Array(frames * MEL_BINS);
167
+ const mask = new Uint8Array(frames);
168
+ const re = new Float64Array(FFT_N);
169
+ const im = new Float64Array(FFT_N);
170
+ const mel = new Float64Array(MEL_BINS);
171
+
172
+ for (let f = 0; f < frames; f++) {
173
+ // frame f covers padded indices [f*HOP, f*HOP+FRAME) after the left pad;
174
+ // validity = the frame-END sample (index f*HOP+FRAME in the left-padded
175
+ // stream, i.e. f*HOP+FRAME-PAD_LEFT in the original) is real audio.
176
+ const endIdx = f * HOP + FRAME - PAD_LEFT;
177
+ const valid = endIdx >= 0 && endIdx < nReal;
178
+ mask[f] = valid ? 1 : 0;
179
+ if (!valid) continue; // oracle zeroes invalid frames; ours start zeroed
180
+
181
+ re.fill(0);
182
+ im.fill(0);
183
+ for (let n = 0; n < FRAME; n++) {
184
+ const srcIdx = f * HOP + n - PAD_LEFT; // <0 → semicausal zero pad
185
+ const sample = srcIdx >= 0 && srcIdx < nReal ? waveform[srcIdx]! : 0;
186
+ re[n] = Math.fround(sample * hannWindow[n]!); // numpy f32 product
187
+ }
188
+ fft512(re, im);
189
+
190
+ mel.fill(0);
191
+ for (let k = 0; k < FREQ_BINS; k++) {
192
+ const mag = Math.hypot(re[k]!, im[k]!);
193
+ if (mag === 0) continue;
194
+ const base = k * MEL_BINS;
195
+ for (let m = 0; m < MEL_BINS; m++) mel[m] = mel[m]! + mag * melFilters![base + m]!;
196
+ }
197
+ const out = f * MEL_BINS;
198
+ for (let m = 0; m < MEL_BINS; m++)
199
+ features[out + m] = Math.fround(Math.log(mel[m]! + MEL_FLOOR));
200
+ }
201
+ return { features, frames, mask };
202
+ }
@@ -0,0 +1,67 @@
1
+ // Non-WAV audio containers → 16 kHz WAV via macOS `afconvert` (CoreAudio —
2
+ // built into darwin, our only platform; zero new dependencies). The serve
3
+ // layer runs every clip through ensureWav(): RIFF/WAVE bytes pass through
4
+ // untouched (decode.ts owns the parse), anything else (mp3/m4a/flac/ogg/
5
+ // aiff/…) round-trips through a temp file under os.tmpdir().
6
+ //
7
+ // Two deliberate choices:
8
+ // - The WAV/non-WAV decision is CONTENT-based (RIFF magic), not
9
+ // metadata-based — a mislabelled `format` field must not break a valid
10
+ // WAV, and an mp3 labelled "wav" still transcodes.
11
+ // - Channel count is preserved (decodeWav does the mono mixdown); the
12
+ // sample rate converts here (`@16000`) because CoreAudio's resampler
13
+ // beats decode.ts's documented linear interpolation for real
14
+ // compressed sources.
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { unlink } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ /** RIFF/WAVE magic sniff ("RIFF" at 0, "WAVE" at 8). */
22
+ export function isRiffWave(bytes: Uint8Array): boolean {
23
+ return (
24
+ bytes.length >= 12 &&
25
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
26
+ bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45
27
+ );
28
+ }
29
+
30
+ /** Transcode any CoreAudio-readable container to 16 kHz PCM16 WAV. Throws
31
+ * with afconvert's diagnostic on failure — the serve layer surfaces it as
32
+ * a 400 ("prompt build failed: audio transcode failed …"). Temp files are
33
+ * cleaned up on every path. */
34
+ export async function transcodeToWav(bytes: Uint8Array): Promise<Uint8Array> {
35
+ const base = join(tmpdir(), `mlx-bun-audio-${randomUUID()}`);
36
+ const inPath = `${base}.in`;
37
+ const outPath = `${base}.wav`;
38
+ await Bun.write(inPath, bytes);
39
+ try {
40
+ const proc = Bun.spawn(
41
+ ["afconvert", "-f", "WAVE", "-d", "LEI16@16000", inPath, outPath],
42
+ { stdout: "ignore", stderr: "pipe" },
43
+ );
44
+ // ≤30 s clips convert in well under a second — a hung afconvert must
45
+ // not pin the request forever. kill() → nonzero exit → the 400 below.
46
+ const killer = setTimeout(() => proc.kill(), 30_000);
47
+ const exit = await proc.exited;
48
+ clearTimeout(killer);
49
+ if (exit !== 0) {
50
+ const err = (await new Response(proc.stderr).text()).trim();
51
+ throw new Error(
52
+ `audio transcode failed (afconvert exit ${exit}): ` +
53
+ `${err.split("\n").at(-1) || "unknown error"} — ` +
54
+ `supported containers: WAV (native), mp3/m4a/flac/ogg/aiff via CoreAudio`,
55
+ );
56
+ }
57
+ return new Uint8Array(await Bun.file(outPath).arrayBuffer());
58
+ } finally {
59
+ await Promise.allSettled([unlink(inPath), unlink(outPath)]);
60
+ }
61
+ }
62
+
63
+ /** WAV passthrough or afconvert transcode — every serve-side clip goes
64
+ * through here before decodeAudio. */
65
+ export async function ensureWav(bytes: Uint8Array): Promise<Uint8Array> {
66
+ return isRiffWave(bytes) ? bytes : transcodeToWav(bytes);
67
+ }
@@ -42,6 +42,126 @@ export interface RenderOptions {
42
42
  enableThinking?: boolean;
43
43
  }
44
44
 
45
+ type ChatRenderer = (
46
+ messages: ChatMessage[],
47
+ options: RenderOptions,
48
+ ) => string;
49
+
50
+ function glm52ContentText(content: ChatMessage["content"], label: string): string {
51
+ if (typeof content === "string") return content;
52
+ if (!Array.isArray(content))
53
+ throw new Error(`${label} must be a string or an array of text parts`);
54
+ return content.map((part, index) => {
55
+ if (
56
+ !part ||
57
+ (part.type !== "text" && part.type !== "input_text") ||
58
+ typeof part.text !== "string"
59
+ ) {
60
+ throw new Error(`${label}.${index}: GLM-5.2 supports text content only`);
61
+ }
62
+ return part.text;
63
+ }).join("");
64
+ }
65
+
66
+ /** Text/tool subset of the pinned GLM-5.2 template used by Colibri. */
67
+ export function renderGlm52Chat(
68
+ messages: ChatMessage[],
69
+ options: RenderOptions = {},
70
+ ): string {
71
+ if (!Array.isArray(messages) || messages.length === 0)
72
+ throw new Error("GLM-5.2 messages must be a non-empty array");
73
+ const {
74
+ addGenerationPrompt = true,
75
+ tools = null,
76
+ enableThinking = false,
77
+ } = options;
78
+ const prompt = ["[gMASK]<sop>"];
79
+ if (enableThinking)
80
+ prompt.push("<|system|>Reasoning Effort: Max");
81
+
82
+ const normalizedTools = normalizeToolSchemas(tools);
83
+ if (normalizedTools && normalizedTools.length > 0) {
84
+ prompt.push(
85
+ "<|system|>\n# Tools\n\nYou may call one or more functions to assist with the " +
86
+ "user query.\n\nYou are provided with function signatures within <tools></tools> " +
87
+ "XML tags:\n<tools>\n",
88
+ );
89
+ for (const tool of normalizedTools) {
90
+ const fn = { ...tool.function } as Record<string, unknown>;
91
+ delete fn.defer_loading;
92
+ delete fn.strict;
93
+ prompt.push(`${JSON.stringify(fn)}\n`);
94
+ }
95
+ prompt.push(
96
+ "</tools>\n\nFor each function call, output the function name and arguments " +
97
+ "within the following XML format:\n<tool_call>{function-name}" +
98
+ "<arg_key>{arg-key-1}</arg_key><arg_value>{arg-value-1}</arg_value>" +
99
+ "<arg_key>{arg-key-2}</arg_key><arg_value>{arg-value-2}</arg_value>...</tool_call>",
100
+ );
101
+ }
102
+
103
+ let previousWasTool = false;
104
+ for (let index = 0; index < messages.length; index++) {
105
+ const message = messages[index]!;
106
+ const role = message.role;
107
+ if (role === "system" || role === "developer") {
108
+ prompt.push(
109
+ `<|system|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
110
+ );
111
+ } else if (role === "user") {
112
+ prompt.push(
113
+ `<|user|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
114
+ );
115
+ } else if (role === "assistant") {
116
+ const content = message.content === null || message.content === undefined
117
+ ? ""
118
+ : glm52ContentText(message.content, `messages.${index}.content`);
119
+ prompt.push(`<|assistant|><think></think>${content.trim()}`);
120
+ for (const call of message.tool_calls ?? []) {
121
+ const raw = call.function.arguments;
122
+ let args: Record<string, unknown> = {};
123
+ if (typeof raw === "string") {
124
+ try {
125
+ const parsed = JSON.parse(raw);
126
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
127
+ args = parsed as Record<string, unknown>;
128
+ } catch {
129
+ // Match the pinned template path: malformed historical arguments
130
+ // contribute an empty call body rather than changing the prompt.
131
+ }
132
+ } else {
133
+ args = raw;
134
+ }
135
+ prompt.push(`<tool_call>${call.function.name}`);
136
+ for (const [key, value] of Object.entries(args)) {
137
+ prompt.push(
138
+ `<arg_key>${key}</arg_key><arg_value>` +
139
+ `${typeof value === "string" ? value : JSON.stringify(value)}</arg_value>`,
140
+ );
141
+ }
142
+ prompt.push("</tool_call>");
143
+ }
144
+ } else if (role === "tool") {
145
+ if (!previousWasTool) prompt.push("<|observation|>");
146
+ prompt.push(
147
+ `<tool_response>${glm52ContentText(message.content, `messages.${index}.content`)}` +
148
+ "</tool_response>",
149
+ );
150
+ } else {
151
+ throw new Error(`GLM-5.2 unsupported message role ${JSON.stringify(role)}`);
152
+ }
153
+ previousWasTool = role === "tool";
154
+ }
155
+ if (addGenerationPrompt) {
156
+ prompt.push(
157
+ enableThinking
158
+ ? "<|assistant|><think>"
159
+ : "<|assistant|><think></think>",
160
+ );
161
+ }
162
+ return prompt.join("");
163
+ }
164
+
45
165
  /** JSON-schema type implied by a literal value. */
46
166
  function jsonTypeOf(v: unknown): string {
47
167
  if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
@@ -116,7 +236,8 @@ export function normalizeToolSchemas(tools: ToolDefinition[] | null): ToolDefini
116
236
  }
117
237
 
118
238
  export class ChatTemplate {
119
- readonly #template: Template;
239
+ readonly #template: Template | null;
240
+ readonly #renderer: ChatRenderer | null;
120
241
  readonly #bosToken: string | null;
121
242
  readonly #eosToken: string | null;
122
243
  /** Which reasoning format the template's `enable_thinking` channel uses, or
@@ -132,15 +253,20 @@ export class ChatTemplate {
132
253
  readonly supportsThinking: boolean;
133
254
 
134
255
  private constructor(
135
- source: string,
256
+ source: string | null,
136
257
  bosToken: string | null,
137
258
  eosToken: string | null,
138
259
  forceNoThinking = false,
260
+ renderer: ChatRenderer | null = null,
261
+ explicitThinkingFormat: "think-tag" | "gemma-channel" | null = null,
139
262
  ) {
140
- this.#template = new Template(source);
263
+ if (source === null && renderer === null)
264
+ throw new Error("ChatTemplate needs a Jinja source or renderer");
265
+ this.#template = source === null ? null : new Template(source);
266
+ this.#renderer = renderer;
141
267
  this.#bosToken = bosToken;
142
268
  this.#eosToken = eosToken;
143
- const gatesThinking = source.includes("enable_thinking");
269
+ const gatesThinking = source?.includes("enable_thinking") ?? false;
144
270
  // `forceNoThinking` suppresses the switchable channel even when the template
145
271
  // carries the gemma markers. DiffusionGemma ships the shared gemma-family
146
272
  // template (with the `<|channel>thought…<channel|>` reasoning channel), but
@@ -149,13 +275,13 @@ export class ChatTemplate {
149
275
  // reasoning, no answer). The OptiQ reference never enables thinking for this
150
276
  // model — it always renders the template's `default(false)` pre-closed empty
151
277
  // channel and decodes the whole canvas as plain text. We match that.
152
- this.thinkingFormat = forceNoThinking
278
+ this.thinkingFormat = explicitThinkingFormat ?? (forceNoThinking
153
279
  ? null
154
- : gatesThinking && source.includes("<think>")
280
+ : gatesThinking && source!.includes("<think>")
155
281
  ? "think-tag"
156
- : gatesThinking && source.includes("<|channel>")
282
+ : gatesThinking && source!.includes("<|channel>")
157
283
  ? "gemma-channel"
158
- : null;
284
+ : null);
159
285
  this.supportsThinking = this.thinkingFormat !== null;
160
286
  }
161
287
 
@@ -169,7 +295,25 @@ export class ChatTemplate {
169
295
  const jinjaFile = Bun.file(`${modelDir}/chat_template.jinja`);
170
296
  if (await jinjaFile.exists()) source = await jinjaFile.text();
171
297
  }
172
- if (!source) throw new Error(`${modelDir}: no chat template found`);
298
+ if (!source) {
299
+ const modelConfigFile = Bun.file(`${modelDir}/config.json`);
300
+ const modelConfig = await modelConfigFile.exists()
301
+ ? await modelConfigFile.json() as Record<string, unknown>
302
+ : null;
303
+ if (modelConfig?.model_type === "glm_moe_dsa") {
304
+ const tokenText = (t: unknown): string | null =>
305
+ t == null ? null : typeof t === "string" ? t : (t as any).content;
306
+ return new ChatTemplate(
307
+ null,
308
+ tokenText(config.bos_token),
309
+ tokenText(config.eos_token),
310
+ false,
311
+ renderGlm52Chat,
312
+ "think-tag",
313
+ );
314
+ }
315
+ throw new Error(`${modelDir}: no chat template found`);
316
+ }
173
317
  // @huggingface/jinja lacks the `min`/`max` array filters that real
174
318
  // Jinja2 has. MiniCPM5's template uses `[a, b]|min` in its assistant
175
319
  // tool-call history branch, so without this rewrite every multi-turn
@@ -189,8 +333,9 @@ export class ChatTemplate {
189
333
  }
190
334
 
191
335
  render(messages: ChatMessage[], options: RenderOptions = {}): string {
336
+ if (this.#renderer) return this.#renderer(messages, options);
192
337
  const { addGenerationPrompt = true, tools = null, enableThinking } = options;
193
- return this.#template.render({
338
+ return this.#template!.render({
194
339
  messages,
195
340
  add_generation_prompt: addGenerationPrompt,
196
341
  // Guarantee every tool param schema has a `type` so templates that do