diskformer 0.6.9 → 0.6.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.
- package/CHANGELOG.md +17 -0
- package/engines/lfm3_omni.js +628 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.11 (2026-10-30)
|
|
4
|
+
- Republishes 0.6.10's tagged files. npm 0.6.10 was packed from a working tree that held uncommitted, unchecked
|
|
5
|
+
speech-output work in `engines/lfm3_omni.js` (854 lines against the tag's 628); 0.6.11 carries the tag's engine.
|
|
6
|
+
No code change from v0.6.10.
|
|
7
|
+
|
|
8
|
+
## 0.6.10 (2026-10-30)
|
|
9
|
+
- LFM3-Omni-2B (`engines/lfm3_omni.js`, `Lfm3Omni`): LiquidAI's ONNX export (decoder_q4f16, vision_encoder_q8,
|
|
10
|
+
audio_encoder; 2.14 GB) on onnxruntime-web 1.30.0's WebGPU EP, because transformers.js 4.3.2 has no `lfm3_omni`
|
|
11
|
+
class. Text, image and speech in (16 kHz, at most 30 s a clip), text out; streaming (`onText`, `onToken`, a
|
|
12
|
+
transformers.js streamer), an AbortSignal, `enable_thinking`, `tools` (tool calls stay raw text). Model files go to
|
|
13
|
+
Cache Storage 'diskformer-models'; `Lfm3Omni.cached()` / `deleteCache()` list and remove them by name.
|
|
14
|
+
- Gate `test/browser/lfm3-omni.html`: prompt ids and 32 greedy tokens equal Liquid's runner on onnxruntime's CPU EP
|
|
15
|
+
(`scripts/lfm3-omni-ref.py`) for text, image and speech, 3/3, plus a reload from Cache Storage with no network
|
|
16
|
+
requests. onnxruntime-web 1.31.1 diverged at token 28 on one case, so the engine pins 1.30.0.
|
|
17
|
+
- anyformer 0.4.13 runs it as `MODELS.chat.LFM3_OMNI_2B` (back end `diskformer-onnx`).
|
|
18
|
+
- `examples/run-demo.mjs --query` passes extra page parameters.
|
|
19
|
+
|
|
3
20
|
## 0.6.9 (2026-10-24)
|
|
4
21
|
- DeepSeek V4.1 Flash 1017 (763B, 6 of 384 experts active) in the chat demo as a tech demo: ISTA-DASLab's GSQ-RCO
|
|
5
22
|
IQ1_M (4 parts, 120.7 GB) plus its separate Engram tables (110.6 GB), 231.3 GB in all, at an 8 GB GPU budget.
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/* lfm3_omni.js — LiquidAI LFM3-Omni-2B (text, image and speech in; text out) on onnxruntime-web's WebGPU EP.
|
|
2
|
+
*
|
|
3
|
+
* transformers.js 4.3.2 has no `lfm3_omni` model class, so this module runs Liquid's ONNX export by hand. It follows
|
|
4
|
+
* Liquid's own Python runner (Liquid4All/onnx-export @ 5167e85: liquidonnx/lfm3_omni/session.py on top of
|
|
5
|
+
* lfm3_audio/session.py and lfm3_vl/session.py), and is gated against scripts/lfm3-omni-ref.py (that runner on
|
|
6
|
+
* onnxruntime's CPU EP): test/lfm3-omni.test.mjs checks the pure-JS parts, test/browser/lfm3-omni.html the tokens.
|
|
7
|
+
*
|
|
8
|
+
* const m = await Lfm3Omni.load({ baseUrl: 'https://huggingface.co/LiquidAI/LFM3-Omni-2B-ONNX/resolve/main/' });
|
|
9
|
+
* const out = await m.generate(
|
|
10
|
+
* [{ role: 'user', content: [{ type: 'image', image }, { type: 'audio', audio }, { type: 'text', text: 'Hi' }] }],
|
|
11
|
+
* { max_new_tokens: 512, do_sample: false, enable_thinking: false, tools, signal, onText: (delta) => … });
|
|
12
|
+
* // out = { text, stopReason: 'eos' | 'length' | 'aborted', tokens, reasoning, content }
|
|
13
|
+
* await m.dispose();
|
|
14
|
+
*
|
|
15
|
+
* Inputs: `image` an ImageData, { data: Uint8Array RGB or RGBA, width, height }, or anything drawImage takes; `audio`
|
|
16
|
+
* a Float32Array of mono 16 kHz samples (toMono16k() resamples an AudioBuffer). Both may also come in order from
|
|
17
|
+
* opts.images / opts.audios, as a transformers.js processor takes them beside the text.
|
|
18
|
+
*
|
|
19
|
+
* Graphs (the model card's WebGPU pairing): decoder_q4f16 (fp16 activations and caches, int4 weights),
|
|
20
|
+
* vision_encoder_q8 and the float32 audio_encoder. Per prompt:
|
|
21
|
+
* chat template → ids; each <|image|> repeats once per image token (grid h·w / merge²), each <|audio|> once per
|
|
22
|
+
* speech token (ceil(mel frames / 8))
|
|
23
|
+
* image: antialiased bicubic resize to multiples of 32 (smart_resize) in torch's uint8 fixed point,
|
|
24
|
+
* (x/255 − 0.5)/0.5, 16×16 patches × 2 frames, block-major → vision_encoder → features [tokens, 2048]
|
|
25
|
+
* audio: log-mel (400-point periodic Hann, hop 160, 128 slaney bins, log10 ≥ −12, (x + 4)/4) → audio_encoder
|
|
26
|
+
* decoder prefill in chunks of ≤ 1,024 ids (each chunk takes the feature rows of its own placeholders), then one
|
|
27
|
+
* id per step; the decoder scatters the rows over the placeholders itself.
|
|
28
|
+
*
|
|
29
|
+
* Text slots follow lfm3_audio.session.generate_ids in TEXT mode: the placeholders (<|audio|>, <|audio_out|>,
|
|
30
|
+
* <|image|>, <|video|>), <|mixed_start|> and <|audio_start|> are suppressed; <|im_end|> or <|text_end|> ends the turn.
|
|
31
|
+
* Speech output (layer 2, not built): the step already returns the last hidden state, which the depth decoder
|
|
32
|
+
* (MaskGIT, 12 codes per frame) reads, and takes `audio_out_codes`, the codes of the frame fed back as <|audio_out|>;
|
|
33
|
+
* output_modalities: ['text', 'audio'] will switch generate() to lfm3_audio's TEXT / AUDIO / INTERLEAVED machine.
|
|
34
|
+
*
|
|
35
|
+
* Model files go to Cache Storage ('diskformer-models', keyed by URL) on the first load and come from there later;
|
|
36
|
+
* load({ force: true }) downloads again, Lfm3Omni.deleteCache({ baseUrl }) removes this model's entries by name.
|
|
37
|
+
* Audio parts are capped at MAX_AUDIO_SECONDS (30 s) per clip.
|
|
38
|
+
*
|
|
39
|
+
* The decoder sits behind Lfm3DecoderStep (step(ids, side) → { logits, hidden }, reset(), dispose()), so a WGSL
|
|
40
|
+
* decoder can replace the ORT one while the encoders stay on ORT.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export const ORT_WEB_URL = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/ort.webgpu.min.mjs';
|
|
44
|
+
export const TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.2/+esm';
|
|
45
|
+
export const LFM3_OMNI_2B = {
|
|
46
|
+
repo: 'LiquidAI/LFM3-Omni-2B-ONNX',
|
|
47
|
+
baseUrl: 'https://huggingface.co/LiquidAI/LFM3-Omni-2B-ONNX/resolve/main/',
|
|
48
|
+
graphs: { decoder: 'onnx/decoder_q4f16.onnx', vision: 'onnx/vision_encoder_q8.onnx', audio: 'onnx/audio_encoder.onnx' },
|
|
49
|
+
json: ['config.json', 'generation_config.json', 'processor_config.json', 'tokenizer.json', 'tokenizer_config.json'],
|
|
50
|
+
};
|
|
51
|
+
// The model card: prefill at most this many ids per decoder run
|
|
52
|
+
export const PREFILL_CHUNK = 1024;
|
|
53
|
+
|
|
54
|
+
// ── Speech in: log-mel features (lfm3_audio.session.log_mel, Lfm3AudioFeatureExtractor) ───────────────────────────
|
|
55
|
+
export const MEL = { sampling_rate: 16000, num_mel_bins: 128, n_fft: 400, win_length: 400, hop_length: 160,
|
|
56
|
+
log_floor: -12, normalization_offset: 4, normalization_divisor: 4, encoder_stride: 8 };
|
|
57
|
+
|
|
58
|
+
export function melConfig(fe = {}) {
|
|
59
|
+
const m = { ...MEL };
|
|
60
|
+
for (const k of Object.keys(MEL)) if (fe[k] !== undefined) m[k] = fe[k];
|
|
61
|
+
if (fe.feature_size !== undefined) m.num_mel_bins = fe.feature_size;
|
|
62
|
+
return m;
|
|
63
|
+
}
|
|
64
|
+
const mod = (a, n) => ((a % n) + n) % n;
|
|
65
|
+
export function melFrames(samples, mel = MEL) {
|
|
66
|
+
const padded = Math.max(mel.n_fft, samples + mod(-(samples - mel.n_fft), mel.hop_length));
|
|
67
|
+
return 1 + Math.floor((padded - mel.n_fft) / mel.hop_length);
|
|
68
|
+
}
|
|
69
|
+
export const speechTokens = (frames, mel = MEL) => Math.ceil(frames / mel.encoder_stride);
|
|
70
|
+
// Longest speech clip per audio part, in seconds (the NakliOS assistant's turn limit)
|
|
71
|
+
export const MAX_AUDIO_SECONDS = 30;
|
|
72
|
+
export function checkAudio(wave, index = 0, maxSeconds = MAX_AUDIO_SECONDS, rate = MEL.sampling_rate) {
|
|
73
|
+
if (!(wave instanceof Float32Array)) throw new TypeError(`audio ${index}: expected a Float32Array of 16 kHz mono samples`);
|
|
74
|
+
if (wave.length > maxSeconds * rate) throw new RangeError(`audio ${index} is ${(wave.length / rate).toFixed(1)} s long; the limit is ${maxSeconds} s per clip`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// [n_fft/2 + 1][num_mel_bins] as a flat Float32Array (bin-major): transformers.audio_utils.mel_filter_bank with the
|
|
78
|
+
// slaney scale and norm from 0 to sampling_rate / 2, rounded to float32 as the Python side does.
|
|
79
|
+
export function melFilters(mel = MEL) {
|
|
80
|
+
const bins = mel.n_fft / 2 + 1, n = mel.num_mel_bins;
|
|
81
|
+
const toMel = (f) => (f >= 1000 ? 15 + Math.log(f / 1000) * (27 / Math.log(6.4)) : (3 * f) / 200);
|
|
82
|
+
const toHz = (m) => (m >= 15 ? 1000 * Math.exp((Math.log(6.4) / 27) * (m - 15)) : (200 * m) / 3);
|
|
83
|
+
const lo = toMel(0), hi = toMel(mel.sampling_rate / 2);
|
|
84
|
+
const f = Array.from({ length: n + 2 }, (_, i) => toHz(lo + ((hi - lo) * i) / (n + 1)));
|
|
85
|
+
const out = new Float32Array(bins * n);
|
|
86
|
+
for (let b = 0; b < bins; b++) {
|
|
87
|
+
const hz = ((mel.sampling_rate / 2) * b) / (bins - 1);
|
|
88
|
+
for (let j = 0; j < n; j++) {
|
|
89
|
+
const down = -(f[j] - hz) / (f[j + 1] - f[j]), up = (f[j + 2] - hz) / (f[j + 2] - f[j + 1]);
|
|
90
|
+
out[b * n + j] = Math.max(0, Math.min(down, up)) * (2 / (f[j + 2] - f[j]));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// input_features [num_mel_bins, T] (row-major Float32Array) of one mono 16 kHz clip: right-padded, |DFT|² of
|
|
97
|
+
// center=False frames under a periodic Hann (float32 window, float64 math), mel filters, log10, clamp, normalize.
|
|
98
|
+
export function logMel(wave, mel = MEL, filters = melFilters(mel)) {
|
|
99
|
+
const N = mel.n_fft, hop = mel.hop_length, bins = N / 2 + 1, nm = mel.num_mel_bins;
|
|
100
|
+
const T = melFrames(wave.length, mel);
|
|
101
|
+
const padded = new Float64Array((T - 1) * hop + N);
|
|
102
|
+
for (let i = 0; i < wave.length; i++) padded[i] = Math.fround(wave[i]);
|
|
103
|
+
const win = new Float64Array(N), off = (N - mel.win_length) >> 1;
|
|
104
|
+
for (let i = 0; i < mel.win_length; i++) win[off + i] = Math.fround(0.5 - 0.5 * Math.cos((2 * Math.PI * i) / mel.win_length));
|
|
105
|
+
const cos = new Float64Array(N), sin = new Float64Array(N);
|
|
106
|
+
for (let i = 0; i < N; i++) { cos[i] = Math.cos((2 * Math.PI * i) / N); sin[i] = Math.sin((2 * Math.PI * i) / N); }
|
|
107
|
+
const out = new Float32Array(nm * T), seg = new Float64Array(N), power = new Float64Array(bins), acc = new Float64Array(nm);
|
|
108
|
+
for (let t = 0; t < T; t++) {
|
|
109
|
+
for (let i = 0; i < N; i++) seg[i] = padded[t * hop + i] * win[i];
|
|
110
|
+
for (let k = 0; k < bins; k++) {
|
|
111
|
+
let re = 0, im = 0;
|
|
112
|
+
for (let i = 0, a = 0; i < N; i++, a = (a + k) % N) { re += seg[i] * cos[a]; im -= seg[i] * sin[a]; }
|
|
113
|
+
power[k] = re * re + im * im;
|
|
114
|
+
}
|
|
115
|
+
acc.fill(0);
|
|
116
|
+
for (let k = 0; k < bins; k++) { const p = power[k]; if (p) for (let j = 0; j < nm; j++) acc[j] += filters[k * nm + j] * p; }
|
|
117
|
+
for (let j = 0; j < nm; j++) {
|
|
118
|
+
const v = Math.max(acc[j] > 0 ? Math.log10(acc[j]) : -Infinity, mel.log_floor);
|
|
119
|
+
out[j * T + t] = (v + mel.normalization_offset) / mel.normalization_divisor;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { data: out, dims: [nm, T] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Mono 16 kHz Float32Array of a decoded AudioBuffer (browser; OfflineAudioContext's resampler, not soxr).
|
|
126
|
+
export async function toMono16k(buffer, rate = MEL.sampling_rate) {
|
|
127
|
+
const ctx = new OfflineAudioContext(1, Math.ceil(buffer.duration * rate), rate);
|
|
128
|
+
const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); src.start();
|
|
129
|
+
return (await ctx.startRendering()).getChannelData(0).slice();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Images: Qwen2VLImageProcessor (transformers 5.19, its torchvision backend) as preprocessor_config.json sets it ──
|
|
133
|
+
export function imageConfig(p = {}) {
|
|
134
|
+
return { patch_size: p.patch_size ?? 16, merge_size: p.merge_size ?? 2, temporal_patch_size: p.temporal_patch_size ?? 2,
|
|
135
|
+
min_pixels: p.size?.shortest_edge ?? 65536, max_pixels: p.size?.longest_edge ?? 2097152,
|
|
136
|
+
mean: p.image_mean ?? [0.5, 0.5, 0.5], std: p.image_std ?? [0.5, 0.5, 0.5], rescale: p.rescale_factor ?? 1 / 255 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Python's round(): halves to even
|
|
140
|
+
const roundEven = (x) => { const r = Math.round(x); return Math.abs(x % 1) === 0.5 && r % 2 ? r - 1 : r; };
|
|
141
|
+
export function smartResize(height, width, factor, minPixels, maxPixels) {
|
|
142
|
+
if (Math.max(height, width) / Math.min(height, width) > 200) throw new Error(`aspect ratio over 200: ${width}×${height}`);
|
|
143
|
+
let h = roundEven(height / factor) * factor, w = roundEven(width / factor) * factor;
|
|
144
|
+
if (h * w > maxPixels) {
|
|
145
|
+
const beta = Math.sqrt((height * width) / maxPixels);
|
|
146
|
+
h = Math.max(factor, Math.floor(height / beta / factor) * factor); w = Math.max(factor, Math.floor(width / beta / factor) * factor);
|
|
147
|
+
} else if (h * w < minPixels) {
|
|
148
|
+
const beta = Math.sqrt(minPixels / (height * width));
|
|
149
|
+
h = Math.ceil((height * beta) / factor) * factor; w = Math.ceil((width * beta) / factor) * factor;
|
|
150
|
+
}
|
|
151
|
+
return [h, w];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// torch's antialiased bicubic weights (a = −0.5; support 2 × scale when shrinking), align_corners=False
|
|
155
|
+
function aaWeights(inSize, outSize) {
|
|
156
|
+
const scale = inSize / outSize, support = scale >= 1 ? 2 * scale : 2, inv = scale >= 1 ? 1 / scale : 1;
|
|
157
|
+
const cubic = (x) => { x = Math.abs(x); return x < 1 ? (1.5 * x - 2.5) * x * x + 1 : x < 2 ? (((x - 5) * x + 8) * x - 4) * -0.5 : 0; };
|
|
158
|
+
const rows = [];
|
|
159
|
+
for (let i = 0; i < outSize; i++) {
|
|
160
|
+
const center = scale * (i + 0.5);
|
|
161
|
+
const lo = Math.max(Math.trunc(center - support + 0.5), 0), hi = Math.min(Math.trunc(center + support + 0.5), inSize);
|
|
162
|
+
const w = []; let total = 0;
|
|
163
|
+
for (let j = lo; j < hi; j++) { const v = cubic((j - center + 0.5) * inv); w.push(v); total += v; }
|
|
164
|
+
rows.push({ lo, w: w.map((v) => (total ? v / total : 0)) });
|
|
165
|
+
}
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The weights of aaWeights as int16 fixed point: torch's uint8 antialias kernel (UpSampleKernel's
|
|
170
|
+
// _compute_index_ranges_int16_weights, Pillow's scheme) takes the most fraction bits that keep the largest weight
|
|
171
|
+
// under 2^15, and rounds each weight away from zero.
|
|
172
|
+
function int16Weights(rows) {
|
|
173
|
+
let wmax = 0;
|
|
174
|
+
for (const r of rows) for (const v of r.w) wmax = Math.max(wmax, Math.abs(v));
|
|
175
|
+
let bits = 0;
|
|
176
|
+
for (; bits < 22; bits++) if (Math.trunc(0.5 + wmax * 2 ** (bits + 1)) >= 2 ** 15) break;
|
|
177
|
+
return { bits, rows: rows.map(({ lo, w }) => ({ lo, w: w.map((v) => Math.trunc(v < 0 ? -0.5 + v * 2 ** bits : 0.5 + v * 2 ** bits)) })) };
|
|
178
|
+
}
|
|
179
|
+
// One pass along an axis in integers: Σ w·x + half, shifted down by the fraction bits, clamped to 0..255.
|
|
180
|
+
function pass(src, n, rows, bits, read, write) {
|
|
181
|
+
const half = 2 ** (bits - 1), div = 2 ** bits;
|
|
182
|
+
for (let o = 0; o < rows.length; o++) {
|
|
183
|
+
const { lo, w } = rows[o];
|
|
184
|
+
for (let line = 0; line < n; line++) {
|
|
185
|
+
let s = half;
|
|
186
|
+
for (let k = 0; k < w.length; k++) s += w[k] * src[read(line, lo + k)];
|
|
187
|
+
write(line, o, Math.min(255, Math.max(0, Math.floor(s / div))));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Planar uint8-valued Float32Array [3][nh][nw] of interleaved uint8 pixels (channels 3 or 4), as torchvision resizes
|
|
193
|
+
// a uint8 tensor with bicubic antialias (torch's uint8 path): width pass to uint8, then height pass to uint8.
|
|
194
|
+
export function resizeBicubic(data, width, height, channels, nw, nh) {
|
|
195
|
+
const out = new Float32Array(3 * nh * nw);
|
|
196
|
+
let tmp = new Uint8Array(3 * height * nw);
|
|
197
|
+
if (nw === width) { for (let c = 0; c < 3; c++) for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) tmp[(c * height + y) * nw + x] = data[(y * width + x) * channels + c]; }
|
|
198
|
+
else {
|
|
199
|
+
const { bits, rows } = int16Weights(aaWeights(width, nw));
|
|
200
|
+
for (let c = 0; c < 3; c++) pass(data, height, rows, bits, (y, x) => (y * width + x) * channels + c, (y, x, v) => { tmp[(c * height + y) * nw + x] = v; });
|
|
201
|
+
}
|
|
202
|
+
if (nh === height) { for (let i = 0; i < out.length; i++) out[i] = tmp[i]; return out; }
|
|
203
|
+
const { bits, rows } = int16Weights(aaWeights(height, nh));
|
|
204
|
+
for (let c = 0; c < 3; c++) pass(tmp, nw, rows, bits, (x, y) => (c * height + y) * nw + x, (x, y, v) => { out[(c * nh + y) * nw + x] = v; });
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// { pixelValues: Float32Array [gh·gw, 3·T·p·p] (block-major rows, the vision encoder's [gh/m, gw/m, m, m, dim]),
|
|
209
|
+
// grid: [1, gh, gw], tokens: gh·gw / m² } of one RGB(A) image
|
|
210
|
+
export function preprocessImage({ data, width, height, channels }, cfg = imageConfig()) {
|
|
211
|
+
channels = channels ?? (data.length === width * height * 4 ? 4 : 3);
|
|
212
|
+
const p = cfg.patch_size, m = cfg.merge_size, T = cfg.temporal_patch_size;
|
|
213
|
+
const [nh, nw] = smartResize(height, width, p * m, cfg.min_pixels, cfg.max_pixels);
|
|
214
|
+
const px = resizeBicubic(data, width, height, channels, nw, nh);
|
|
215
|
+
for (let c = 0; c < 3; c++) for (let i = c * nh * nw; i < (c + 1) * nh * nw; i++) px[i] = (px[i] * cfg.rescale - cfg.mean[c]) / cfg.std[c];
|
|
216
|
+
const gh = nh / p, gw = nw / p, dim = 3 * T * p * p, pv = new Float32Array(gh * gw * dim);
|
|
217
|
+
let row = 0;
|
|
218
|
+
for (let bh = 0; bh < gh / m; bh++) for (let bw = 0; bw < gw / m; bw++) for (let mh = 0; mh < m; mh++) for (let mw = 0; mw < m; mw++, row++) {
|
|
219
|
+
const y0 = (bh * m + mh) * p, x0 = (bw * m + mw) * p;
|
|
220
|
+
let o = row * dim;
|
|
221
|
+
for (let c = 0; c < 3; c++) for (let t = 0; t < T; t++) for (let y = 0; y < p; y++) {
|
|
222
|
+
const base = (c * nh + y0 + y) * nw + x0;
|
|
223
|
+
for (let x = 0; x < p; x++) pv[o++] = px[base + x];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { pixelValues: pv, grid: [1, gh, gw], tokens: (gh * gw) / (m * m), dims: [gh / m, gw / m, m, m, dim] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// RGB(A) pixels of an image input: raw { data, width, height } as is, else drawn through an OffscreenCanvas (RGBA)
|
|
230
|
+
async function pixelsOf(image) {
|
|
231
|
+
if (image && image.data && image.width && image.height) return image;
|
|
232
|
+
const bmp = typeof createImageBitmap === 'function' && !(image instanceof ImageBitmap) ? await createImageBitmap(image) : image;
|
|
233
|
+
const c = new OffscreenCanvas(bmp.width, bmp.height), g = c.getContext('2d');
|
|
234
|
+
g.drawImage(bmp, 0, 0);
|
|
235
|
+
const d = g.getImageData(0, 0, bmp.width, bmp.height);
|
|
236
|
+
return { data: d.data, width: d.width, height: d.height, channels: 4 };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Prompt ids ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
// ids with the n-th occurrence of `token` repeated counts[n] times (Lfm3AudioProcessor, expand_image_tokens)
|
|
241
|
+
export function expandPlaceholders(ids, token, counts) {
|
|
242
|
+
const found = ids.filter((t) => t === token).length;
|
|
243
|
+
if (found !== counts.length) throw new Error(`prompt has ${found} placeholders ${token}, got ${counts.length} inputs`);
|
|
244
|
+
const out = []; let n = 0;
|
|
245
|
+
for (const t of ids) if (t === token) for (let k = counts[n++]; k > 0; k--) out.push(t); else out.push(t);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Messages for the chat template: media parts keep only their type (the template writes the placeholder);
|
|
250
|
+
// the media themselves, in prompt order, from the parts or else from images / audios.
|
|
251
|
+
export function splitMedia(messages, images = [], audios = []) {
|
|
252
|
+
const media = { images: [], audios: [] }; let ni = 0, na = 0;
|
|
253
|
+
const strip = messages.map((msg) => {
|
|
254
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
255
|
+
return { ...msg, content: msg.content.map((part) => {
|
|
256
|
+
if (part?.type === 'image') { media.images.push(part.image ?? images[ni]); ni++; return { type: 'image' }; }
|
|
257
|
+
if (part?.type === 'audio') { media.audios.push(part.audio ?? audios[na]); na++; return { type: 'audio' }; }
|
|
258
|
+
return part;
|
|
259
|
+
}) };
|
|
260
|
+
});
|
|
261
|
+
for (const [k, list] of Object.entries(media)) list.forEach((x, i) => { if (!x) throw new Error(`no data for ${k.slice(0, -1)} ${i}`); });
|
|
262
|
+
return { messages: strip, ...media };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Sampling (lfm3_audio.session.sample_token) ──────────────────────────────────────────────────────────────────────
|
|
266
|
+
export function mulberry32(seed) {
|
|
267
|
+
return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
268
|
+
}
|
|
269
|
+
// One id from logits: repetition penalty over history, suppressed ids to −inf, argmax unless sampling, else
|
|
270
|
+
// temperature, top-k, top-p, min-p and a draw from rand.
|
|
271
|
+
export function sampleToken(logits, { do_sample = false, temperature = 1, top_k = 0, top_p = 1, min_p = 0, repetition_penalty = 1 } = {}, history = [], suppress = [], rand = Math.random) {
|
|
272
|
+
const s = Float32Array.from(logits);
|
|
273
|
+
if (repetition_penalty !== 1) for (const t of new Set(history)) s[t] = s[t] < 0 ? s[t] * repetition_penalty : s[t] / repetition_penalty;
|
|
274
|
+
for (const t of suppress) s[t] = -Infinity;
|
|
275
|
+
if (!do_sample || temperature === 0) { let b = 0; for (let i = 1; i < s.length; i++) if (s[i] > s[b]) b = i; return b; }
|
|
276
|
+
let ids = Array.from(s.keys()).filter((i) => s[i] > -Infinity).sort((a, b) => s[b] - s[a]);
|
|
277
|
+
if (top_k > 0) ids = ids.slice(0, top_k);
|
|
278
|
+
const mx = s[ids[0]] / temperature;
|
|
279
|
+
let p = ids.map((i) => Math.exp(s[i] / temperature - mx)), z = p.reduce((a, b) => a + b, 0);
|
|
280
|
+
p = p.map((x) => x / z);
|
|
281
|
+
let keep = ids.length;
|
|
282
|
+
if (top_p < 1) { let c = 0; keep = 0; while (keep < ids.length && c < top_p) c += p[keep++]; }
|
|
283
|
+
if (min_p > 0) keep = Math.min(keep, p.filter((x) => x >= min_p * p[0]).length);
|
|
284
|
+
ids = ids.slice(0, Math.max(1, keep)); p = p.slice(0, ids.length); z = p.reduce((a, b) => a + b, 0);
|
|
285
|
+
let r = rand() * z;
|
|
286
|
+
for (let k = 0; k < ids.length; k++) { r -= p[k]; if (r <= 0) return ids[k]; }
|
|
287
|
+
return ids[ids.length - 1];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── The decoder step on ORT ──────────────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
// Lfm3DecoderStep: what generate() needs from a decoder, whatever runs it.
|
|
292
|
+
// step(ids, side) → { logits: Float32Array [vocab], hidden: Float32Array [hidden] } at the last id, after the ids so
|
|
293
|
+
// far; side = { image_features, video_features, audio_features: Float32Array rows × hidden, one row per
|
|
294
|
+
// placeholder among `ids`, in order; audio_out_codes: BigInt64Array rows × 12, one per <|audio_out|> }
|
|
295
|
+
// past (ids so far), reset() (drops the caches), dispose()
|
|
296
|
+
export class OrtLfm3Decoder {
|
|
297
|
+
constructor(ort, session, text) {
|
|
298
|
+
this.ort = ort; this.session = session; this.hidden = text.hidden_size;
|
|
299
|
+
this.inputs = new Set(session.inputNames);
|
|
300
|
+
this.layers = text.layer_types.map((type, i) => ({ i, conv: type === 'conv' }));
|
|
301
|
+
this.convShape = [1, text.hidden_size, text.conv_L_cache];
|
|
302
|
+
this.kvShape = [1, text.num_key_value_heads, 0, text.head_dim];
|
|
303
|
+
this.cache = null; this.past = 0;
|
|
304
|
+
}
|
|
305
|
+
static async create(ort, source, opts, text) {
|
|
306
|
+
const gpu = {};
|
|
307
|
+
text.layer_types.forEach((type, i) => { if (type === 'conv') gpu[`present_conv.${i}`] = 'gpu-buffer'; else { gpu[`present.${i}.key`] = 'gpu-buffer'; gpu[`present.${i}.value`] = 'gpu-buffer'; } });
|
|
308
|
+
const session = await createSession(ort, source, { ...opts, preferredOutputLocation: opts.device === 'webgpu' ? gpu : undefined });
|
|
309
|
+
return new OrtLfm3Decoder(ort, session, text);
|
|
310
|
+
}
|
|
311
|
+
#empty() {
|
|
312
|
+
const T = this.ort.Tensor, cache = {};
|
|
313
|
+
for (const { i, conv } of this.layers) {
|
|
314
|
+
if (conv) cache[`past_conv.${i}`] = new T('float16', new Uint16Array(this.convShape.reduce((a, b) => a * b)), this.convShape);
|
|
315
|
+
else for (const kv of ['key', 'value']) cache[`past_key_values.${i}.${kv}`] = new T('float16', new Uint16Array(0), this.kvShape);
|
|
316
|
+
}
|
|
317
|
+
return cache;
|
|
318
|
+
}
|
|
319
|
+
async step(ids, side = {}) {
|
|
320
|
+
const T = this.ort.Tensor, n = ids.length, H = this.hidden;
|
|
321
|
+
if (!this.cache) this.cache = this.#empty();
|
|
322
|
+
const i64 = (arr) => BigInt64Array.from(arr, (x) => BigInt(x));
|
|
323
|
+
const rows = (x) => (x && x.length ? x : new Float32Array(0));
|
|
324
|
+
const feeds = {
|
|
325
|
+
input_ids: new T('int64', i64(ids), [1, n]),
|
|
326
|
+
attention_mask: new T('int64', new BigInt64Array(this.past + n).fill(1n), [1, this.past + n]),
|
|
327
|
+
position_ids: new T('int64', i64(Array.from({ length: n }, (_, k) => this.past + k)), [1, n]),
|
|
328
|
+
logits_to_keep: new T('int64', BigInt64Array.of(1n), [1]),
|
|
329
|
+
...this.cache,
|
|
330
|
+
};
|
|
331
|
+
for (const name of ['image_features', 'video_features', 'audio_features']) {
|
|
332
|
+
const x = rows(side[name]);
|
|
333
|
+
if (this.inputs.has(name)) feeds[name] = new T('float32', x, [x.length / H, H]);
|
|
334
|
+
}
|
|
335
|
+
const codes = side.audio_out_codes && side.audio_out_codes.length ? side.audio_out_codes : new BigInt64Array(0);
|
|
336
|
+
if (this.inputs.has('audio_out_codes')) feeds.audio_out_codes = new T('int64', codes, [codes.length / 12, 12]);
|
|
337
|
+
const out = await this.session.run(feeds);
|
|
338
|
+
const next = {};
|
|
339
|
+
for (const [name, t] of Object.entries(out)) {
|
|
340
|
+
if (name.startsWith('present_conv.')) next[name.replace('present_conv.', 'past_conv.')] = t;
|
|
341
|
+
else if (name.startsWith('present.')) next[name.replace('present.', 'past_key_values.')] = t;
|
|
342
|
+
}
|
|
343
|
+
for (const t of Object.values(this.cache)) t.dispose?.();
|
|
344
|
+
this.cache = next; this.past += n;
|
|
345
|
+
const logits = out.logits.data, hidden = out.hidden_states.data, V = logits.length / out.logits.dims[1];
|
|
346
|
+
const res = { logits: logits.slice(logits.length - V), hidden: Float32Array.from(hidden.subarray(hidden.length - H)) };
|
|
347
|
+
out.logits.dispose?.(); out.hidden_states.dispose?.();
|
|
348
|
+
return res;
|
|
349
|
+
}
|
|
350
|
+
reset() { if (this.cache) for (const t of Object.values(this.cache)) t.dispose?.(); this.cache = null; this.past = 0; }
|
|
351
|
+
async dispose() { this.reset(); await this.session.release(); }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function createSession(ort, { model, data, dataPath }, { device = 'webgpu', preferredOutputLocation } = {}) {
|
|
355
|
+
const ep = device === 'webgpu' ? { name: 'webgpu', enableMatmulFp32Accumulation: true } : device;
|
|
356
|
+
return ort.InferenceSession.create(model, {
|
|
357
|
+
executionProviders: [ep], graphOptimizationLevel: 'all',
|
|
358
|
+
externalData: data ? [{ path: dataPath, data }] : undefined,
|
|
359
|
+
...(preferredOutputLocation ? { preferredOutputLocation } : {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Model files: Cache Storage (as kohra's fetchCached) ─────────────────────────────────────────────────────────────
|
|
364
|
+
// One Cache Storage cache for diskformer's ONNX engines; each entry is keyed by its file URL, so deleteCache() can
|
|
365
|
+
// remove one model's entries by name. (GGUF engines keep their weights in OPFS stores instead: src/ingest.js.)
|
|
366
|
+
export const CACHE_NAME = 'diskformer-models';
|
|
367
|
+
|
|
368
|
+
// Bytes of url: a Cache Storage hit is read from disk; a miss is downloaded once, its stream split between the cache
|
|
369
|
+
// (written to disk as it arrives) and one buffer here, so a 1.4 GB file is never held twice by this module. A
|
|
370
|
+
// failed put (quota) still returns the bytes; the next load downloads again. force: skip the stored copy and replace
|
|
371
|
+
// it. onProgress({ status: 'download' | 'cache', loaded, total }); fetchImpl for tests.
|
|
372
|
+
export async function fetchCached(url, { cache = null, force = false, onProgress = () => {}, fetchImpl = fetch } = {}) {
|
|
373
|
+
let res = cache && !force ? await cache.match(url) : null;
|
|
374
|
+
const hit = !!res;
|
|
375
|
+
if (!res) {
|
|
376
|
+
res = await fetchImpl(url);
|
|
377
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
378
|
+
}
|
|
379
|
+
const status = hit ? 'cache' : 'download';
|
|
380
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
381
|
+
let body = res.body, stored = null;
|
|
382
|
+
if (cache && !hit && body) {
|
|
383
|
+
const [mine, theirs] = body.tee();
|
|
384
|
+
body = mine;
|
|
385
|
+
stored = cache.put(url, new Response(theirs, total ? { headers: { 'content-length': String(total) } } : {})).catch(() => {});
|
|
386
|
+
}
|
|
387
|
+
if (!body) { const b = new Uint8Array(await res.arrayBuffer()); onProgress({ status, loaded: b.length, total: b.length }); return b; }
|
|
388
|
+
// straight into one buffer of the announced size; chunks only when the size is unknown or the body runs past it
|
|
389
|
+
// (a compressed response announces the compressed length)
|
|
390
|
+
const bytes = new Uint8Array(total), chunks = [], reader = body.getReader();
|
|
391
|
+
let loaded = 0, filled = 0;
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { done, value } = await reader.read();
|
|
394
|
+
if (done) break;
|
|
395
|
+
if (!chunks.length && filled + value.length <= total) { bytes.set(value, filled); filled += value.length; } else chunks.push(value);
|
|
396
|
+
loaded += value.length;
|
|
397
|
+
onProgress({ status, loaded, total: Math.max(total, loaded) });
|
|
398
|
+
}
|
|
399
|
+
await stored;
|
|
400
|
+
if (hit && total && loaded !== total) { // a stored copy cut short: drop it and download again
|
|
401
|
+
await cache.delete(url);
|
|
402
|
+
return fetchCached(url, { cache, force: true, onProgress, fetchImpl });
|
|
403
|
+
}
|
|
404
|
+
if (!chunks.length && filled === total) return bytes;
|
|
405
|
+
const out = new Uint8Array(loaded);
|
|
406
|
+
out.set(bytes.subarray(0, filled)); let o = filled;
|
|
407
|
+
for (const c of chunks) { out.set(c, o); o += c.length; }
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── The engine ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
412
|
+
export class Lfm3Omni {
|
|
413
|
+
// opts: baseUrl (the repo layout) or files { 'onnx/decoder_q4f16.onnx': URL | Blob | ArrayBuffer | Uint8Array, … };
|
|
414
|
+
// ort (onnxruntime-web/webgpu module), transformers (for PreTrainedTokenizer) or tokenizer; device 'webgpu';
|
|
415
|
+
// onProgress({ status: 'download' | 'cache', file, loaded, total }) while a file arrives, then 'session' per graph;
|
|
416
|
+
// cacheName (CACHE_NAME; null: no Cache Storage) and force (download again, replacing the stored copies)
|
|
417
|
+
static async load(opts = {}) {
|
|
418
|
+
const { baseUrl = LFM3_OMNI_2B.baseUrl, files = {}, device = 'webgpu', onProgress = () => {}, cacheName = CACHE_NAME, force = false } = opts;
|
|
419
|
+
const src = (name) => files[name] ?? new URL(name, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href;
|
|
420
|
+
const cache = cacheName && globalThis.caches ? await caches.open(cacheName) : null;
|
|
421
|
+
const bytes = async (name) => {
|
|
422
|
+
const s = src(name);
|
|
423
|
+
if (s instanceof Uint8Array) return s;
|
|
424
|
+
if (s instanceof ArrayBuffer) return new Uint8Array(s);
|
|
425
|
+
if (s instanceof Blob) return new Uint8Array(await s.arrayBuffer());
|
|
426
|
+
return fetchCached(s, { cache, force, onProgress: (e) => onProgress({ ...e, file: name }) });
|
|
427
|
+
};
|
|
428
|
+
const json = async (name) => JSON.parse(new TextDecoder().decode(await bytes(name)));
|
|
429
|
+
const text = async (name) => new TextDecoder().decode(await bytes(name));
|
|
430
|
+
|
|
431
|
+
onProgress({ status: 'config' });
|
|
432
|
+
const [config, gen, processor] = await Promise.all([json('config.json'), json('generation_config.json').catch(() => ({})), json('processor_config.json')]);
|
|
433
|
+
if (config.model_type !== 'lfm3_omni') throw new Error(`not an LFM3-Omni config: ${config.model_type}`);
|
|
434
|
+
let tokenizer = opts.tokenizer;
|
|
435
|
+
if (!tokenizer) {
|
|
436
|
+
onProgress({ status: 'tokenizer' });
|
|
437
|
+
const tf = opts.transformers ?? (await import(TRANSFORMERS_URL));
|
|
438
|
+
const [tj, tc, tpl] = await Promise.all([json('tokenizer.json'), json('tokenizer_config.json'), text('chat_template.jinja')]);
|
|
439
|
+
tokenizer = new tf.PreTrainedTokenizer(tj, { ...tc, chat_template: tc.chat_template ?? tpl });
|
|
440
|
+
}
|
|
441
|
+
const ort = opts.ort ?? (await import(ORT_WEB_URL));
|
|
442
|
+
// the graph and its external data, both read here (byte progress); the session is built from the bytes, which
|
|
443
|
+
// are dropped once onnxruntime holds the weights
|
|
444
|
+
const graph = async (role) => {
|
|
445
|
+
const file = LFM3_OMNI_2B.graphs[role];
|
|
446
|
+
const model = await bytes(file), data = await bytes(file + '_data');
|
|
447
|
+
onProgress({ status: 'session', file });
|
|
448
|
+
return { model, data, dataPath: file.split('/').pop() + '_data' };
|
|
449
|
+
};
|
|
450
|
+
const sessionOpts = { device };
|
|
451
|
+
const vision = await createSession(ort, await graph('vision'), sessionOpts);
|
|
452
|
+
const audio = await createSession(ort, await graph('audio'), sessionOpts);
|
|
453
|
+
const decoder = await OrtLfm3Decoder.create(ort, await graph('decoder'), sessionOpts, config.text_config);
|
|
454
|
+
onProgress({ status: 'ready' });
|
|
455
|
+
return new Lfm3Omni({ ort, tokenizer, config, gen, processor, vision, audio, decoder });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Every file load() reads, relative to baseUrl
|
|
459
|
+
static files() {
|
|
460
|
+
return [...LFM3_OMNI_2B.json, 'chat_template.jinja', ...Object.values(LFM3_OMNI_2B.graphs).flatMap((g) => [g, g + '_data'])];
|
|
461
|
+
}
|
|
462
|
+
// Stored copies of this model's files under baseUrl: [{ file, url, bytes }] (bytes from the stored length)
|
|
463
|
+
static async cached({ baseUrl = LFM3_OMNI_2B.baseUrl, cacheName = CACHE_NAME } = {}) {
|
|
464
|
+
if (!globalThis.caches) return [];
|
|
465
|
+
const cache = await caches.open(cacheName), out = [];
|
|
466
|
+
for (const file of Lfm3Omni.files()) {
|
|
467
|
+
const url = new URL(file, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href, r = await cache.match(url);
|
|
468
|
+
if (r) out.push({ file, url, bytes: Number(r.headers.get('content-length')) || null });
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
// Deletes this model's stored files by name (other entries in the cache stay); resolves to the files deleted
|
|
473
|
+
static async deleteCache({ baseUrl = LFM3_OMNI_2B.baseUrl, cacheName = CACHE_NAME } = {}) {
|
|
474
|
+
if (!globalThis.caches) return [];
|
|
475
|
+
const cache = await caches.open(cacheName), gone = [];
|
|
476
|
+
for (const { file, url } of await Lfm3Omni.cached({ baseUrl, cacheName })) if (await cache.delete(url)) gone.push(file);
|
|
477
|
+
return gone;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
constructor({ ort, tokenizer, config, gen, processor, vision, audio, decoder }) {
|
|
481
|
+
Object.assign(this, { ort, tokenizer, config, generationConfig: gen, vision, audio, decoder });
|
|
482
|
+
// true from load until dispose
|
|
483
|
+
this.ready = !!decoder;
|
|
484
|
+
this.hidden = config.text_config.hidden_size;
|
|
485
|
+
// ids per prefill run (the model card's cap; a test lowers it to cut placeholders across runs)
|
|
486
|
+
this.prefillChunk = PREFILL_CHUNK;
|
|
487
|
+
this.mel = melConfig(processor.feature_extractor);
|
|
488
|
+
this.melFilters = melFilters(this.mel);
|
|
489
|
+
this.image = imageConfig(processor.image_processor);
|
|
490
|
+
const t = (k, d) => config[k] ?? d;
|
|
491
|
+
this.tokens = {
|
|
492
|
+
audio: t('audio_token_id', 125031), audio_out: t('audio_out_token_id', 125032), image: t('image_token_id', 125030),
|
|
493
|
+
video: t('video_token_id', 125029), audio_start: t('audio_start_token_id', 125012), text_end: t('text_end_token_id', 125014),
|
|
494
|
+
mixed_start: t('mixed_start_token_id', 125015), eos: config.text_config.eos_token_id,
|
|
495
|
+
think_end: tokenizer.convert_tokens_to_ids?.('</think>') ?? null,
|
|
496
|
+
};
|
|
497
|
+
// the speech-output layer reads these (lfm3_audio.config): not used in text mode
|
|
498
|
+
this.speech = { num_codebooks: t('num_codebooks', 12), codebook_size: t('codebook_size', 2017), audio_eos_code: t('audio_eos_code', 2016),
|
|
499
|
+
maskgit_schedule: t('maskgit_schedule', [4, 4, 4]), interleaved_n_text: t('interleaved_n_text', 6), interleaved_n_audio: t('interleaved_n_audio', 12) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// The chat template's text and ids (placeholders expanded) of messages; media come back for the encoders.
|
|
503
|
+
async prepare(messages, { images = [], audios = [], enable_thinking = false, tools, add_generation_prompt = true, max_audio_seconds = MAX_AUDIO_SECONDS } = {}) {
|
|
504
|
+
const m = splitMedia(messages, images, audios);
|
|
505
|
+
m.audios.forEach((wave, i) => checkAudio(wave, i, max_audio_seconds, this.mel.sampling_rate));
|
|
506
|
+
const prompt = this.tokenizer.apply_chat_template(m.messages, { tokenize: false, add_generation_prompt, enable_thinking, ...(tools ? { tools } : {}) });
|
|
507
|
+
let ids = Array.from(this.tokenizer.encode(prompt, { add_special_tokens: false }), Number);
|
|
508
|
+
const pics = [];
|
|
509
|
+
for (const img of m.images) pics.push(preprocessImage(await pixelsOf(img), this.image));
|
|
510
|
+
const mels = m.audios.map((wave) => logMel(wave, this.mel, this.melFilters));
|
|
511
|
+
ids = expandPlaceholders(ids, this.tokens.audio, mels.map((x) => speechTokens(x.dims[1], this.mel)));
|
|
512
|
+
ids = expandPlaceholders(ids, this.tokens.image, pics.map((x) => x.tokens));
|
|
513
|
+
return { prompt, ids, pics, mels };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// image_features [Σ tokens × hidden] of preprocessed images: one vision run per image, in prompt order
|
|
517
|
+
async encodeImages(pics) {
|
|
518
|
+
const parts = [];
|
|
519
|
+
for (const p of pics) {
|
|
520
|
+
const out = await this.vision.run({ pixel_values: new this.ort.Tensor('float32', p.pixelValues, p.dims) });
|
|
521
|
+
const f = out.image_features;
|
|
522
|
+
if (f.dims[0] !== p.tokens) throw new Error(`grid ${p.grid} gave ${f.dims[0]} image features, expected ${p.tokens}`);
|
|
523
|
+
parts.push(Float32Array.from(f.data)); f.dispose?.();
|
|
524
|
+
}
|
|
525
|
+
return concat(parts);
|
|
526
|
+
}
|
|
527
|
+
// audio_features [Σ tokens × hidden] of log-mel clips: one audio_encoder run per clip
|
|
528
|
+
async encodeAudio(mels) {
|
|
529
|
+
const parts = [];
|
|
530
|
+
for (const m of mels) {
|
|
531
|
+
const out = await this.audio.run({ input_features: new this.ort.Tensor('float32', m.data, [1, ...m.dims]) });
|
|
532
|
+
const f = out.audio_features;
|
|
533
|
+
if (f.dims[0] !== speechTokens(m.dims[1], this.mel)) throw new Error(`${m.dims[1]} mel frames gave ${f.dims[0]} audio features`);
|
|
534
|
+
parts.push(Float32Array.from(f.data)); f.dispose?.();
|
|
535
|
+
}
|
|
536
|
+
return concat(parts);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Prefill ids in chunks of ≤ prefillChunk; each chunk takes the feature rows of its own placeholders.
|
|
540
|
+
async prefill(ids, side) {
|
|
541
|
+
const H = this.hidden, at = { image_features: 0, audio_features: 0 }, token = { image_features: this.tokens.image, audio_features: this.tokens.audio };
|
|
542
|
+
let last;
|
|
543
|
+
for (let s = 0; s < ids.length; s += this.prefillChunk) {
|
|
544
|
+
const chunk = ids.slice(s, s + this.prefillChunk), feed = {};
|
|
545
|
+
for (const k of Object.keys(at)) {
|
|
546
|
+
const n = chunk.filter((t) => t === token[k]).length;
|
|
547
|
+
feed[k] = side[k] ? side[k].subarray(at[k] * H, (at[k] + n) * H) : undefined;
|
|
548
|
+
at[k] += n;
|
|
549
|
+
}
|
|
550
|
+
last = await this.decoder.step(chunk, feed);
|
|
551
|
+
}
|
|
552
|
+
for (const k of Object.keys(at)) if ((side[k]?.length ?? 0) !== at[k] * H) throw new Error(`${at[k]} placeholders for ${(side[k]?.length ?? 0) / H} ${k} rows`);
|
|
553
|
+
return last;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Answer to messages, with transformers.js-style options (max_new_tokens, do_sample, temperature, top_k, top_p,
|
|
557
|
+
// min_p, repetition_penalty, enable_thinking, tools, seed). Resolves { text, stopReason, tokens, reasoning, content }:
|
|
558
|
+
// text is the raw answer (special tokens skipped; <think> and <tool_call> markup kept, the caller parses it),
|
|
559
|
+
// stopReason 'eos' | 'length' | 'aborted'. Streaming: onText(delta) with each new piece of text (held back while
|
|
560
|
+
// a token ends inside a UTF-8 character), onToken(id), and a transformers.js streamer (put([[id]]), end()). signal
|
|
561
|
+
// (AbortSignal) is read before every decode step; an abort resolves, it does not throw.
|
|
562
|
+
async generate(messages, opts = {}) {
|
|
563
|
+
const { max_new_tokens = 1024, signal, onText, onToken, streamer, seed, output_modalities = ['text'] } = opts;
|
|
564
|
+
if (output_modalities.includes('audio')) throw new Error('LFM3-Omni speech output is not built yet (text only)');
|
|
565
|
+
const sampling = { do_sample: opts.do_sample ?? false, temperature: opts.temperature ?? 1, top_k: opts.top_k ?? 0,
|
|
566
|
+
top_p: opts.top_p ?? 1, min_p: opts.min_p ?? 0, repetition_penalty: opts.repetition_penalty ?? 1 };
|
|
567
|
+
const rand = seed === undefined ? Math.random : mulberry32(seed);
|
|
568
|
+
const T = this.tokens, tokens = [], history = [];
|
|
569
|
+
const suppress = [T.audio, T.audio_out, T.image, T.video, T.mixed_start, T.audio_start];
|
|
570
|
+
let stopReason = 'length', sent = 0;
|
|
571
|
+
const emit = () => {
|
|
572
|
+
const full = this.tokenizer.decode(tokens, { skip_special_tokens: true });
|
|
573
|
+
if (full.length > sent && !full.endsWith('\uFFFD')) { onText(full.slice(sent)); sent = full.length; }
|
|
574
|
+
};
|
|
575
|
+
if (signal?.aborted) return { text: '', stopReason: 'aborted', tokens, reasoning: '', content: '' };
|
|
576
|
+
const { ids, pics, mels } = await this.prepare(messages, opts);
|
|
577
|
+
const side = { image_features: pics.length ? await this.encodeImages(pics) : undefined, audio_features: mels.length ? await this.encodeAudio(mels) : undefined };
|
|
578
|
+
try {
|
|
579
|
+
let { logits } = await this.prefill(ids, side);
|
|
580
|
+
// TEXT mode of lfm3_audio's slot machine; the speech layer adds AUDIO / INTERLEAVED slots here, read the step's
|
|
581
|
+
// hidden state, and feeds frames back as <|audio_out|> with audio_out_codes
|
|
582
|
+
for (let step = 0; step < max_new_tokens; step++) {
|
|
583
|
+
if (signal?.aborted) { stopReason = 'aborted'; break; }
|
|
584
|
+
const tok = sampleToken(logits, sampling, history, suppress, rand);
|
|
585
|
+
if (tok === T.eos || tok === T.text_end) { stopReason = 'eos'; break; }
|
|
586
|
+
tokens.push(tok); history.push(tok); if (history.length > 32) history.shift();
|
|
587
|
+
streamer?.put?.([[BigInt(tok)]]); onToken?.(tok);
|
|
588
|
+
if (onText) emit();
|
|
589
|
+
if (step === max_new_tokens - 1) break;
|
|
590
|
+
({ logits } = await this.decoder.step([tok]));
|
|
591
|
+
}
|
|
592
|
+
} finally { this.decoder.reset(); streamer?.end?.(); }
|
|
593
|
+
const text = this.tokenizer.decode(tokens, { skip_special_tokens: true });
|
|
594
|
+
if (onText && text.length > sent) onText(text.slice(sent));
|
|
595
|
+
return { text, stopReason, tokens, ...this.decodeText(tokens) };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Greedy ids after prompt ids (the gate's comparison): no template, no stop on EOS unless asked.
|
|
599
|
+
async greedyIds(ids, side = {}, n = 32, { stopAtEos = true } = {}) {
|
|
600
|
+
const T = this.tokens, suppress = [T.audio, T.audio_out, T.image, T.video, T.mixed_start, T.audio_start], out = [];
|
|
601
|
+
try {
|
|
602
|
+
let { logits } = await this.prefill(ids, side);
|
|
603
|
+
for (let k = 0; k < n; k++) {
|
|
604
|
+
const tok = sampleToken(logits, {}, [], suppress);
|
|
605
|
+
out.push(tok);
|
|
606
|
+
if (k === n - 1 || (stopAtEos && (tok === T.eos || tok === T.text_end))) break;
|
|
607
|
+
({ logits } = await this.decoder.step([tok]));
|
|
608
|
+
}
|
|
609
|
+
} finally { this.decoder.reset(); }
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// { content, reasoning }: the answer after </think> (special tokens skipped), and what the think block held
|
|
614
|
+
decodeText(tokens) {
|
|
615
|
+
const T = this.tokens, k = T.think_end == null ? -1 : tokens.indexOf(T.think_end);
|
|
616
|
+
const dec = (t) => this.tokenizer.decode(t, { skip_special_tokens: true });
|
|
617
|
+
if (k < 0) return { content: dec(tokens), reasoning: '' };
|
|
618
|
+
return { content: dec(tokens.slice(k + 1)).trimStart(), reasoning: dec(tokens.slice(0, k)).replace(/^\s*<think>/, '').trim() };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async dispose() { this.ready = false; await this.decoder.dispose(); await this.vision.release(); await this.audio.release(); }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function concat(parts) {
|
|
625
|
+
const out = new Float32Array(parts.reduce((a, p) => a + p.length, 0));
|
|
626
|
+
let o = 0; for (const p of parts) { out.set(p, o); o += p.length; }
|
|
627
|
+
return out;
|
|
628
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diskformer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Run a model larger than your GPU memory in a browser tab: weights stay in OPFS, WebGPU holds only what each step needs. llama.cpp with paged MoE experts (diskformer/llama), a disk tier, and hand-written MoE engines.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|