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