diskformer 0.6.8 → 0.6.10
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 +25 -0
- package/engines/lfm3_omni.js +854 -0
- package/ggml/dist/BUILD.md +2 -2
- package/ggml/dist/df-chat.wasm +0 -0
- package/ggml/llama.js +3 -2
- package/ggml/worker.mjs +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.10 (2026-10-30)
|
|
4
|
+
- LFM3-Omni-2B (`engines/lfm3_omni.js`, `Lfm3Omni`): LiquidAI's ONNX export (decoder_q4f16, vision_encoder_q8,
|
|
5
|
+
audio_encoder; 2.14 GB) on onnxruntime-web 1.30.0's WebGPU EP, because transformers.js 4.3.2 has no `lfm3_omni`
|
|
6
|
+
class. Text, image and speech in (16 kHz, at most 30 s a clip), text out; streaming (`onText`, `onToken`, a
|
|
7
|
+
transformers.js streamer), an AbortSignal, `enable_thinking`, `tools` (tool calls stay raw text). Model files go to
|
|
8
|
+
Cache Storage 'diskformer-models'; `Lfm3Omni.cached()` / `deleteCache()` list and remove them by name.
|
|
9
|
+
- Gate `test/browser/lfm3-omni.html`: prompt ids and 32 greedy tokens equal Liquid's runner on onnxruntime's CPU EP
|
|
10
|
+
(`scripts/lfm3-omni-ref.py`) for text, image and speech, 3/3, plus a reload from Cache Storage with no network
|
|
11
|
+
requests. onnxruntime-web 1.31.1 diverged at token 28 on one case, so the engine pins 1.30.0.
|
|
12
|
+
- anyformer 0.4.13 runs it as `MODELS.chat.LFM3_OMNI_2B` (back end `diskformer-onnx`).
|
|
13
|
+
- `examples/run-demo.mjs --query` passes extra page parameters.
|
|
14
|
+
|
|
15
|
+
## 0.6.9 (2026-10-24)
|
|
16
|
+
- DeepSeek V4.1 Flash 1017 (763B, 6 of 384 experts active) in the chat demo as a tech demo: ISTA-DASLab's GSQ-RCO
|
|
17
|
+
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.
|
|
18
|
+
In Chrome it loaded in 173 s and answered one prompt as the native build does ("The capital of France is
|
|
19
|
+
**Paris**."); about 0.4 tok/s natively from an external SSD (1.3 GB of experts read per token). Layers 0-2 on
|
|
20
|
+
llama.cpp's CPU backend match a float64 reference within llama.cpp's own rounding. Not gated on 9 prompts.
|
|
21
|
+
- The fork (`webgpu-diskformer` at `5ac033a52`) takes draft PR 30817 (DeepSeek-V4.1) with a loader for ISTA's
|
|
22
|
+
layout (separate Engram file, unfused gate, u32 candidate layer) and WebGPU shaders for its ops:
|
|
23
|
+
LIGHTNING_INDEXER, DSV4_HC_PRE/POST/COMB, TOP_K_MASK, POOL_1D, ROPE_BACK, f16 CONCAT and BF16 CPY.
|
|
24
|
+
Regression gates on the new build: Granite 4.0 H Tiny 9/9 and Nemotron 3.5 Bolt E4B 9/9 in Chrome.
|
|
25
|
+
- `load({ extra })` mounts stored side files beside the GGUF; the chat demo downloads a catalog entry's side
|
|
26
|
+
files with its parts. `scripts/chrome-gate.mjs --extra` and `--dist`.
|
|
27
|
+
|
|
3
28
|
## 0.6.8 (2026-10-24)
|
|
4
29
|
- Nemotron 3.5 Bolt E4B (dense, `gemma4`, 6.18 GB) in the chat demo: 9/9 replies identical to llama-server on WebGPU
|
|
5
30
|
in Chrome, 30 tok/s at a 5 GB budget. Three fixes made it run in a browser:
|
|
@@ -0,0 +1,854 @@
|
|
|
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 (output_modalities: ['text', 'audio']) is lfm3-omni-infer's s2s mode, lfm3_audio.session.generate_ids'
|
|
32
|
+
* TEXT / AUDIO / INTERLEAVED machine: the system turn "Respond with interleaved text and audio." (when the messages
|
|
33
|
+
* have none) and <|mixed_start|> after the generation prompt; then 6 text slots and 12 speech frames in turn until
|
|
34
|
+
* <|text_end|>, after which speech goes on to its end-of-audio frame. A frame is 12 codes from the fp32 depth
|
|
35
|
+
* decoder, MaskGIT in 3 passes of 4 codebooks over the decoder's hidden state (audio_temperature 0.4, repetition
|
|
36
|
+
* penalty 1.2 over the last 64 frames, from generation_config.json); it goes back into the decoder as <|audio_out|>
|
|
37
|
+
* with audio_out_codes. Every 12 frames (and at the end) the fp32 codec decoder turns the new frames, with 20 frames
|
|
38
|
+
* of left context, into STFT rows, and an overlap-add inverse STFT gives 24 kHz Float32 PCM to onAudio.
|
|
39
|
+
*
|
|
40
|
+
* Model files go to Cache Storage ('diskformer-models', keyed by URL) on the first load and come from there later;
|
|
41
|
+
* load({ force: true }) downloads again, Lfm3Omni.deleteCache({ baseUrl }) removes this model's entries by name.
|
|
42
|
+
* Audio parts are capped at MAX_AUDIO_SECONDS (30 s) per clip.
|
|
43
|
+
*
|
|
44
|
+
* The decoder sits behind Lfm3DecoderStep (step(ids, side) → { logits, hidden }, reset(), dispose()), so a WGSL
|
|
45
|
+
* decoder can replace the ORT one while the encoders stay on ORT.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export const ORT_WEB_URL = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/ort.webgpu.min.mjs';
|
|
49
|
+
export const TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.2/+esm';
|
|
50
|
+
export const LFM3_OMNI_2B = {
|
|
51
|
+
repo: 'LiquidAI/LFM3-Omni-2B-ONNX',
|
|
52
|
+
baseUrl: 'https://huggingface.co/LiquidAI/LFM3-Omni-2B-ONNX/resolve/main/',
|
|
53
|
+
graphs: { decoder: 'onnx/decoder_q4f16.onnx', vision: 'onnx/vision_encoder_q8.onnx', audio: 'onnx/audio_encoder.onnx' },
|
|
54
|
+
// speech out, loaded with load({ speech: true }) or on the first generate() that asks for audio
|
|
55
|
+
speechGraphs: { depth: 'onnx/depth_decoder.onnx', codec: 'onnx/codec_decoder.onnx' },
|
|
56
|
+
json: ['config.json', 'generation_config.json', 'processor_config.json', 'tokenizer.json', 'tokenizer_config.json'],
|
|
57
|
+
};
|
|
58
|
+
// The model card: prefill at most this many ids per decoder run
|
|
59
|
+
export const PREFILL_CHUNK = 1024;
|
|
60
|
+
|
|
61
|
+
// ── Speech in: log-mel features (lfm3_audio.session.log_mel, Lfm3AudioFeatureExtractor) ───────────────────────────
|
|
62
|
+
export const MEL = { sampling_rate: 16000, num_mel_bins: 128, n_fft: 400, win_length: 400, hop_length: 160,
|
|
63
|
+
log_floor: -12, normalization_offset: 4, normalization_divisor: 4, encoder_stride: 8 };
|
|
64
|
+
|
|
65
|
+
export function melConfig(fe = {}) {
|
|
66
|
+
const m = { ...MEL };
|
|
67
|
+
for (const k of Object.keys(MEL)) if (fe[k] !== undefined) m[k] = fe[k];
|
|
68
|
+
if (fe.feature_size !== undefined) m.num_mel_bins = fe.feature_size;
|
|
69
|
+
return m;
|
|
70
|
+
}
|
|
71
|
+
const mod = (a, n) => ((a % n) + n) % n;
|
|
72
|
+
export function melFrames(samples, mel = MEL) {
|
|
73
|
+
const padded = Math.max(mel.n_fft, samples + mod(-(samples - mel.n_fft), mel.hop_length));
|
|
74
|
+
return 1 + Math.floor((padded - mel.n_fft) / mel.hop_length);
|
|
75
|
+
}
|
|
76
|
+
export const speechTokens = (frames, mel = MEL) => Math.ceil(frames / mel.encoder_stride);
|
|
77
|
+
// Longest speech clip per audio part, in seconds (the NakliOS assistant's turn limit)
|
|
78
|
+
export const MAX_AUDIO_SECONDS = 30;
|
|
79
|
+
export function checkAudio(wave, index = 0, maxSeconds = MAX_AUDIO_SECONDS, rate = MEL.sampling_rate) {
|
|
80
|
+
if (!(wave instanceof Float32Array)) throw new TypeError(`audio ${index}: expected a Float32Array of 16 kHz mono samples`);
|
|
81
|
+
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`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// [n_fft/2 + 1][num_mel_bins] as a flat Float32Array (bin-major): transformers.audio_utils.mel_filter_bank with the
|
|
85
|
+
// slaney scale and norm from 0 to sampling_rate / 2, rounded to float32 as the Python side does.
|
|
86
|
+
export function melFilters(mel = MEL) {
|
|
87
|
+
const bins = mel.n_fft / 2 + 1, n = mel.num_mel_bins;
|
|
88
|
+
const toMel = (f) => (f >= 1000 ? 15 + Math.log(f / 1000) * (27 / Math.log(6.4)) : (3 * f) / 200);
|
|
89
|
+
const toHz = (m) => (m >= 15 ? 1000 * Math.exp((Math.log(6.4) / 27) * (m - 15)) : (200 * m) / 3);
|
|
90
|
+
const lo = toMel(0), hi = toMel(mel.sampling_rate / 2);
|
|
91
|
+
const f = Array.from({ length: n + 2 }, (_, i) => toHz(lo + ((hi - lo) * i) / (n + 1)));
|
|
92
|
+
const out = new Float32Array(bins * n);
|
|
93
|
+
for (let b = 0; b < bins; b++) {
|
|
94
|
+
const hz = ((mel.sampling_rate / 2) * b) / (bins - 1);
|
|
95
|
+
for (let j = 0; j < n; j++) {
|
|
96
|
+
const down = -(f[j] - hz) / (f[j + 1] - f[j]), up = (f[j + 2] - hz) / (f[j + 2] - f[j + 1]);
|
|
97
|
+
out[b * n + j] = Math.max(0, Math.min(down, up)) * (2 / (f[j + 2] - f[j]));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// input_features [num_mel_bins, T] (row-major Float32Array) of one mono 16 kHz clip: right-padded, |DFT|² of
|
|
104
|
+
// center=False frames under a periodic Hann (float32 window, float64 math), mel filters, log10, clamp, normalize.
|
|
105
|
+
export function logMel(wave, mel = MEL, filters = melFilters(mel)) {
|
|
106
|
+
const N = mel.n_fft, hop = mel.hop_length, bins = N / 2 + 1, nm = mel.num_mel_bins;
|
|
107
|
+
const T = melFrames(wave.length, mel);
|
|
108
|
+
const padded = new Float64Array((T - 1) * hop + N);
|
|
109
|
+
for (let i = 0; i < wave.length; i++) padded[i] = Math.fround(wave[i]);
|
|
110
|
+
const win = new Float64Array(N), off = (N - mel.win_length) >> 1;
|
|
111
|
+
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));
|
|
112
|
+
const cos = new Float64Array(N), sin = new Float64Array(N);
|
|
113
|
+
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); }
|
|
114
|
+
const out = new Float32Array(nm * T), seg = new Float64Array(N), power = new Float64Array(bins), acc = new Float64Array(nm);
|
|
115
|
+
for (let t = 0; t < T; t++) {
|
|
116
|
+
for (let i = 0; i < N; i++) seg[i] = padded[t * hop + i] * win[i];
|
|
117
|
+
for (let k = 0; k < bins; k++) {
|
|
118
|
+
let re = 0, im = 0;
|
|
119
|
+
for (let i = 0, a = 0; i < N; i++, a = (a + k) % N) { re += seg[i] * cos[a]; im -= seg[i] * sin[a]; }
|
|
120
|
+
power[k] = re * re + im * im;
|
|
121
|
+
}
|
|
122
|
+
acc.fill(0);
|
|
123
|
+
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; }
|
|
124
|
+
for (let j = 0; j < nm; j++) {
|
|
125
|
+
const v = Math.max(acc[j] > 0 ? Math.log10(acc[j]) : -Infinity, mel.log_floor);
|
|
126
|
+
out[j * T + t] = (v + mel.normalization_offset) / mel.normalization_divisor;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { data: out, dims: [nm, T] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mono 16 kHz Float32Array of a decoded AudioBuffer (browser; OfflineAudioContext's resampler, not soxr).
|
|
133
|
+
export async function toMono16k(buffer, rate = MEL.sampling_rate) {
|
|
134
|
+
const ctx = new OfflineAudioContext(1, Math.ceil(buffer.duration * rate), rate);
|
|
135
|
+
const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); src.start();
|
|
136
|
+
return (await ctx.startRendering()).getChannelData(0).slice();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Images: Qwen2VLImageProcessor (transformers 5.19, its torchvision backend) as preprocessor_config.json sets it ──
|
|
140
|
+
export function imageConfig(p = {}) {
|
|
141
|
+
return { patch_size: p.patch_size ?? 16, merge_size: p.merge_size ?? 2, temporal_patch_size: p.temporal_patch_size ?? 2,
|
|
142
|
+
min_pixels: p.size?.shortest_edge ?? 65536, max_pixels: p.size?.longest_edge ?? 2097152,
|
|
143
|
+
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 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Python's round(): halves to even
|
|
147
|
+
const roundEven = (x) => { const r = Math.round(x); return Math.abs(x % 1) === 0.5 && r % 2 ? r - 1 : r; };
|
|
148
|
+
export function smartResize(height, width, factor, minPixels, maxPixels) {
|
|
149
|
+
if (Math.max(height, width) / Math.min(height, width) > 200) throw new Error(`aspect ratio over 200: ${width}×${height}`);
|
|
150
|
+
let h = roundEven(height / factor) * factor, w = roundEven(width / factor) * factor;
|
|
151
|
+
if (h * w > maxPixels) {
|
|
152
|
+
const beta = Math.sqrt((height * width) / maxPixels);
|
|
153
|
+
h = Math.max(factor, Math.floor(height / beta / factor) * factor); w = Math.max(factor, Math.floor(width / beta / factor) * factor);
|
|
154
|
+
} else if (h * w < minPixels) {
|
|
155
|
+
const beta = Math.sqrt(minPixels / (height * width));
|
|
156
|
+
h = Math.ceil((height * beta) / factor) * factor; w = Math.ceil((width * beta) / factor) * factor;
|
|
157
|
+
}
|
|
158
|
+
return [h, w];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// torch's antialiased bicubic weights (a = −0.5; support 2 × scale when shrinking), align_corners=False
|
|
162
|
+
function aaWeights(inSize, outSize) {
|
|
163
|
+
const scale = inSize / outSize, support = scale >= 1 ? 2 * scale : 2, inv = scale >= 1 ? 1 / scale : 1;
|
|
164
|
+
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; };
|
|
165
|
+
const rows = [];
|
|
166
|
+
for (let i = 0; i < outSize; i++) {
|
|
167
|
+
const center = scale * (i + 0.5);
|
|
168
|
+
const lo = Math.max(Math.trunc(center - support + 0.5), 0), hi = Math.min(Math.trunc(center + support + 0.5), inSize);
|
|
169
|
+
const w = []; let total = 0;
|
|
170
|
+
for (let j = lo; j < hi; j++) { const v = cubic((j - center + 0.5) * inv); w.push(v); total += v; }
|
|
171
|
+
rows.push({ lo, w: w.map((v) => (total ? v / total : 0)) });
|
|
172
|
+
}
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The weights of aaWeights as int16 fixed point: torch's uint8 antialias kernel (UpSampleKernel's
|
|
177
|
+
// _compute_index_ranges_int16_weights, Pillow's scheme) takes the most fraction bits that keep the largest weight
|
|
178
|
+
// under 2^15, and rounds each weight away from zero.
|
|
179
|
+
function int16Weights(rows) {
|
|
180
|
+
let wmax = 0;
|
|
181
|
+
for (const r of rows) for (const v of r.w) wmax = Math.max(wmax, Math.abs(v));
|
|
182
|
+
let bits = 0;
|
|
183
|
+
for (; bits < 22; bits++) if (Math.trunc(0.5 + wmax * 2 ** (bits + 1)) >= 2 ** 15) break;
|
|
184
|
+
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)) })) };
|
|
185
|
+
}
|
|
186
|
+
// One pass along an axis in integers: Σ w·x + half, shifted down by the fraction bits, clamped to 0..255.
|
|
187
|
+
function pass(src, n, rows, bits, read, write) {
|
|
188
|
+
const half = 2 ** (bits - 1), div = 2 ** bits;
|
|
189
|
+
for (let o = 0; o < rows.length; o++) {
|
|
190
|
+
const { lo, w } = rows[o];
|
|
191
|
+
for (let line = 0; line < n; line++) {
|
|
192
|
+
let s = half;
|
|
193
|
+
for (let k = 0; k < w.length; k++) s += w[k] * src[read(line, lo + k)];
|
|
194
|
+
write(line, o, Math.min(255, Math.max(0, Math.floor(s / div))));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Planar uint8-valued Float32Array [3][nh][nw] of interleaved uint8 pixels (channels 3 or 4), as torchvision resizes
|
|
200
|
+
// a uint8 tensor with bicubic antialias (torch's uint8 path): width pass to uint8, then height pass to uint8.
|
|
201
|
+
export function resizeBicubic(data, width, height, channels, nw, nh) {
|
|
202
|
+
const out = new Float32Array(3 * nh * nw);
|
|
203
|
+
let tmp = new Uint8Array(3 * height * nw);
|
|
204
|
+
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]; }
|
|
205
|
+
else {
|
|
206
|
+
const { bits, rows } = int16Weights(aaWeights(width, nw));
|
|
207
|
+
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; });
|
|
208
|
+
}
|
|
209
|
+
if (nh === height) { for (let i = 0; i < out.length; i++) out[i] = tmp[i]; return out; }
|
|
210
|
+
const { bits, rows } = int16Weights(aaWeights(height, nh));
|
|
211
|
+
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; });
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// { pixelValues: Float32Array [gh·gw, 3·T·p·p] (block-major rows, the vision encoder's [gh/m, gw/m, m, m, dim]),
|
|
216
|
+
// grid: [1, gh, gw], tokens: gh·gw / m² } of one RGB(A) image
|
|
217
|
+
export function preprocessImage({ data, width, height, channels }, cfg = imageConfig()) {
|
|
218
|
+
channels = channels ?? (data.length === width * height * 4 ? 4 : 3);
|
|
219
|
+
const p = cfg.patch_size, m = cfg.merge_size, T = cfg.temporal_patch_size;
|
|
220
|
+
const [nh, nw] = smartResize(height, width, p * m, cfg.min_pixels, cfg.max_pixels);
|
|
221
|
+
const px = resizeBicubic(data, width, height, channels, nw, nh);
|
|
222
|
+
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];
|
|
223
|
+
const gh = nh / p, gw = nw / p, dim = 3 * T * p * p, pv = new Float32Array(gh * gw * dim);
|
|
224
|
+
let row = 0;
|
|
225
|
+
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++) {
|
|
226
|
+
const y0 = (bh * m + mh) * p, x0 = (bw * m + mw) * p;
|
|
227
|
+
let o = row * dim;
|
|
228
|
+
for (let c = 0; c < 3; c++) for (let t = 0; t < T; t++) for (let y = 0; y < p; y++) {
|
|
229
|
+
const base = (c * nh + y0 + y) * nw + x0;
|
|
230
|
+
for (let x = 0; x < p; x++) pv[o++] = px[base + x];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { pixelValues: pv, grid: [1, gh, gw], tokens: (gh * gw) / (m * m), dims: [gh / m, gw / m, m, m, dim] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// RGB(A) pixels of an image input: raw { data, width, height } as is, else drawn through an OffscreenCanvas (RGBA)
|
|
237
|
+
async function pixelsOf(image) {
|
|
238
|
+
if (image && image.data && image.width && image.height) return image;
|
|
239
|
+
const bmp = typeof createImageBitmap === 'function' && !(image instanceof ImageBitmap) ? await createImageBitmap(image) : image;
|
|
240
|
+
const c = new OffscreenCanvas(bmp.width, bmp.height), g = c.getContext('2d');
|
|
241
|
+
g.drawImage(bmp, 0, 0);
|
|
242
|
+
const d = g.getImageData(0, 0, bmp.width, bmp.height);
|
|
243
|
+
return { data: d.data, width: d.width, height: d.height, channels: 4 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Prompt ids ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
// ids with the n-th occurrence of `token` repeated counts[n] times (Lfm3AudioProcessor, expand_image_tokens)
|
|
248
|
+
export function expandPlaceholders(ids, token, counts) {
|
|
249
|
+
const found = ids.filter((t) => t === token).length;
|
|
250
|
+
if (found !== counts.length) throw new Error(`prompt has ${found} placeholders ${token}, got ${counts.length} inputs`);
|
|
251
|
+
const out = []; let n = 0;
|
|
252
|
+
for (const t of ids) if (t === token) for (let k = counts[n++]; k > 0; k--) out.push(t); else out.push(t);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Messages for the chat template: media parts keep only their type (the template writes the placeholder);
|
|
257
|
+
// the media themselves, in prompt order, from the parts or else from images / audios.
|
|
258
|
+
export function splitMedia(messages, images = [], audios = []) {
|
|
259
|
+
const media = { images: [], audios: [] }; let ni = 0, na = 0;
|
|
260
|
+
const strip = messages.map((msg) => {
|
|
261
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
262
|
+
return { ...msg, content: msg.content.map((part) => {
|
|
263
|
+
if (part?.type === 'image') { media.images.push(part.image ?? images[ni]); ni++; return { type: 'image' }; }
|
|
264
|
+
if (part?.type === 'audio') { media.audios.push(part.audio ?? audios[na]); na++; return { type: 'audio' }; }
|
|
265
|
+
return part;
|
|
266
|
+
}) };
|
|
267
|
+
});
|
|
268
|
+
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}`); });
|
|
269
|
+
return { messages: strip, ...media };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Sampling (lfm3_audio.session.sample_token) ──────────────────────────────────────────────────────────────────────
|
|
273
|
+
export function mulberry32(seed) {
|
|
274
|
+
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; };
|
|
275
|
+
}
|
|
276
|
+
// One id from logits: repetition penalty over history, suppressed ids to −inf, argmax unless sampling, else
|
|
277
|
+
// temperature, top-k, top-p, min-p and a draw from rand.
|
|
278
|
+
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) {
|
|
279
|
+
const s = Float32Array.from(logits);
|
|
280
|
+
if (repetition_penalty !== 1) for (const t of new Set(history)) s[t] = s[t] < 0 ? s[t] * repetition_penalty : s[t] / repetition_penalty;
|
|
281
|
+
for (const t of suppress) s[t] = -Infinity;
|
|
282
|
+
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; }
|
|
283
|
+
let ids = Array.from(s.keys()).filter((i) => s[i] > -Infinity).sort((a, b) => s[b] - s[a]);
|
|
284
|
+
if (top_k > 0) ids = ids.slice(0, top_k);
|
|
285
|
+
const mx = s[ids[0]] / temperature;
|
|
286
|
+
let p = ids.map((i) => Math.exp(s[i] / temperature - mx)), z = p.reduce((a, b) => a + b, 0);
|
|
287
|
+
p = p.map((x) => x / z);
|
|
288
|
+
let keep = ids.length;
|
|
289
|
+
if (top_p < 1) { let c = 0; keep = 0; while (keep < ids.length && c < top_p) c += p[keep++]; }
|
|
290
|
+
if (min_p > 0) keep = Math.min(keep, p.filter((x) => x >= min_p * p[0]).length);
|
|
291
|
+
ids = ids.slice(0, Math.max(1, keep)); p = p.slice(0, ids.length); z = p.reduce((a, b) => a + b, 0);
|
|
292
|
+
let r = rand() * z;
|
|
293
|
+
for (let k = 0; k < ids.length; k++) { r -= p[k]; if (r <= 0) return ids[k]; }
|
|
294
|
+
return ids[ids.length - 1];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── The decoder step on ORT ──────────────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
// Lfm3DecoderStep: what generate() needs from a decoder, whatever runs it.
|
|
299
|
+
// step(ids, side) → { logits: Float32Array [vocab], hidden: Float32Array [hidden] } at the last id, after the ids so
|
|
300
|
+
// far; side = { image_features, video_features, audio_features: Float32Array rows × hidden, one row per
|
|
301
|
+
// placeholder among `ids`, in order; audio_out_codes: BigInt64Array rows × 12, one per <|audio_out|> }
|
|
302
|
+
// past (ids so far), reset() (drops the caches), dispose()
|
|
303
|
+
export class OrtLfm3Decoder {
|
|
304
|
+
constructor(ort, session, text) {
|
|
305
|
+
this.ort = ort; this.session = session; this.hidden = text.hidden_size;
|
|
306
|
+
this.inputs = new Set(session.inputNames);
|
|
307
|
+
this.layers = text.layer_types.map((type, i) => ({ i, conv: type === 'conv' }));
|
|
308
|
+
this.convShape = [1, text.hidden_size, text.conv_L_cache];
|
|
309
|
+
this.kvShape = [1, text.num_key_value_heads, 0, text.head_dim];
|
|
310
|
+
this.cache = null; this.past = 0;
|
|
311
|
+
}
|
|
312
|
+
static async create(ort, source, opts, text) {
|
|
313
|
+
const gpu = {};
|
|
314
|
+
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'; } });
|
|
315
|
+
const session = await createSession(ort, source, { ...opts, preferredOutputLocation: opts.device === 'webgpu' ? gpu : undefined });
|
|
316
|
+
return new OrtLfm3Decoder(ort, session, text);
|
|
317
|
+
}
|
|
318
|
+
#empty() {
|
|
319
|
+
const T = this.ort.Tensor, cache = {};
|
|
320
|
+
for (const { i, conv } of this.layers) {
|
|
321
|
+
if (conv) cache[`past_conv.${i}`] = new T('float16', new Uint16Array(this.convShape.reduce((a, b) => a * b)), this.convShape);
|
|
322
|
+
else for (const kv of ['key', 'value']) cache[`past_key_values.${i}.${kv}`] = new T('float16', new Uint16Array(0), this.kvShape);
|
|
323
|
+
}
|
|
324
|
+
return cache;
|
|
325
|
+
}
|
|
326
|
+
async step(ids, side = {}) {
|
|
327
|
+
const T = this.ort.Tensor, n = ids.length, H = this.hidden;
|
|
328
|
+
if (!this.cache) this.cache = this.#empty();
|
|
329
|
+
const i64 = (arr) => BigInt64Array.from(arr, (x) => BigInt(x));
|
|
330
|
+
const rows = (x) => (x && x.length ? x : new Float32Array(0));
|
|
331
|
+
const feeds = {
|
|
332
|
+
input_ids: new T('int64', i64(ids), [1, n]),
|
|
333
|
+
attention_mask: new T('int64', new BigInt64Array(this.past + n).fill(1n), [1, this.past + n]),
|
|
334
|
+
position_ids: new T('int64', i64(Array.from({ length: n }, (_, k) => this.past + k)), [1, n]),
|
|
335
|
+
logits_to_keep: new T('int64', BigInt64Array.of(1n), [1]),
|
|
336
|
+
...this.cache,
|
|
337
|
+
};
|
|
338
|
+
for (const name of ['image_features', 'video_features', 'audio_features']) {
|
|
339
|
+
const x = rows(side[name]);
|
|
340
|
+
if (this.inputs.has(name)) feeds[name] = new T('float32', x, [x.length / H, H]);
|
|
341
|
+
}
|
|
342
|
+
const codes = side.audio_out_codes && side.audio_out_codes.length ? side.audio_out_codes : new BigInt64Array(0);
|
|
343
|
+
if (this.inputs.has('audio_out_codes')) feeds.audio_out_codes = new T('int64', codes, [codes.length / 12, 12]);
|
|
344
|
+
const out = await this.session.run(feeds);
|
|
345
|
+
const next = {};
|
|
346
|
+
for (const [name, t] of Object.entries(out)) {
|
|
347
|
+
if (name.startsWith('present_conv.')) next[name.replace('present_conv.', 'past_conv.')] = t;
|
|
348
|
+
else if (name.startsWith('present.')) next[name.replace('present.', 'past_key_values.')] = t;
|
|
349
|
+
}
|
|
350
|
+
for (const t of Object.values(this.cache)) t.dispose?.();
|
|
351
|
+
this.cache = next; this.past += n;
|
|
352
|
+
const logits = out.logits.data, hidden = out.hidden_states.data, V = logits.length / out.logits.dims[1];
|
|
353
|
+
const res = { logits: logits.slice(logits.length - V), hidden: Float32Array.from(hidden.subarray(hidden.length - H)) };
|
|
354
|
+
out.logits.dispose?.(); out.hidden_states.dispose?.();
|
|
355
|
+
return res;
|
|
356
|
+
}
|
|
357
|
+
reset() { if (this.cache) for (const t of Object.values(this.cache)) t.dispose?.(); this.cache = null; this.past = 0; }
|
|
358
|
+
async dispose() { this.reset(); await this.session.release(); }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function createSession(ort, { model, data, dataPath }, { device = 'webgpu', preferredOutputLocation } = {}) {
|
|
362
|
+
const ep = device === 'webgpu' ? { name: 'webgpu', enableMatmulFp32Accumulation: true } : device;
|
|
363
|
+
return ort.InferenceSession.create(model, {
|
|
364
|
+
executionProviders: [ep], graphOptimizationLevel: 'all',
|
|
365
|
+
externalData: data ? [{ path: dataPath, data }] : undefined,
|
|
366
|
+
...(preferredOutputLocation ? { preferredOutputLocation } : {}),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Model files: Cache Storage (as kohra's fetchCached) ─────────────────────────────────────────────────────────────
|
|
371
|
+
// One Cache Storage cache for diskformer's ONNX engines; each entry is keyed by its file URL, so deleteCache() can
|
|
372
|
+
// remove one model's entries by name. (GGUF engines keep their weights in OPFS stores instead: src/ingest.js.)
|
|
373
|
+
export const CACHE_NAME = 'diskformer-models';
|
|
374
|
+
|
|
375
|
+
// Bytes of url: a Cache Storage hit is read from disk; a miss is downloaded once, its stream split between the cache
|
|
376
|
+
// (written to disk as it arrives) and one buffer here, so a 1.4 GB file is never held twice by this module. A
|
|
377
|
+
// failed put (quota) still returns the bytes; the next load downloads again. force: skip the stored copy and replace
|
|
378
|
+
// it. onProgress({ status: 'download' | 'cache', loaded, total }); fetchImpl for tests.
|
|
379
|
+
export async function fetchCached(url, { cache = null, force = false, onProgress = () => {}, fetchImpl = fetch } = {}) {
|
|
380
|
+
let res = cache && !force ? await cache.match(url) : null;
|
|
381
|
+
const hit = !!res;
|
|
382
|
+
if (!res) {
|
|
383
|
+
res = await fetchImpl(url);
|
|
384
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
385
|
+
}
|
|
386
|
+
const status = hit ? 'cache' : 'download';
|
|
387
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
388
|
+
let body = res.body, stored = null;
|
|
389
|
+
if (cache && !hit && body) {
|
|
390
|
+
const [mine, theirs] = body.tee();
|
|
391
|
+
body = mine;
|
|
392
|
+
stored = cache.put(url, new Response(theirs, total ? { headers: { 'content-length': String(total) } } : {})).catch(() => {});
|
|
393
|
+
}
|
|
394
|
+
if (!body) { const b = new Uint8Array(await res.arrayBuffer()); onProgress({ status, loaded: b.length, total: b.length }); return b; }
|
|
395
|
+
// straight into one buffer of the announced size; chunks only when the size is unknown or the body runs past it
|
|
396
|
+
// (a compressed response announces the compressed length)
|
|
397
|
+
const bytes = new Uint8Array(total), chunks = [], reader = body.getReader();
|
|
398
|
+
let loaded = 0, filled = 0;
|
|
399
|
+
for (;;) {
|
|
400
|
+
const { done, value } = await reader.read();
|
|
401
|
+
if (done) break;
|
|
402
|
+
if (!chunks.length && filled + value.length <= total) { bytes.set(value, filled); filled += value.length; } else chunks.push(value);
|
|
403
|
+
loaded += value.length;
|
|
404
|
+
onProgress({ status, loaded, total: Math.max(total, loaded) });
|
|
405
|
+
}
|
|
406
|
+
await stored;
|
|
407
|
+
if (hit && total && loaded !== total) { // a stored copy cut short: drop it and download again
|
|
408
|
+
await cache.delete(url);
|
|
409
|
+
return fetchCached(url, { cache, force: true, onProgress, fetchImpl });
|
|
410
|
+
}
|
|
411
|
+
if (!chunks.length && filled === total) return bytes;
|
|
412
|
+
const out = new Uint8Array(loaded);
|
|
413
|
+
out.set(bytes.subarray(0, filled)); let o = filled;
|
|
414
|
+
for (const c of chunks) { out.set(c, o); o += c.length; }
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Speech out: MaskGIT frames, the codec, the inverse STFT ───────────────────────────────────────────────────────
|
|
419
|
+
// Codec frames re-decoded before each chunk's new frames. The codec is causal (5 conv layers of kernel 7, 3 attention
|
|
420
|
+
// layers of window 30, over 6 rows per frame), so a row sees at most 5·6 + 3·29 = 117 rows back: 20 frames covers it.
|
|
421
|
+
export const CODEC_CONTEXT_FRAMES = 20;
|
|
422
|
+
export const SPEECH_SYSTEM_PROMPT = 'Respond with interleaved text and audio.';
|
|
423
|
+
|
|
424
|
+
// A random permutation of 0..n-1 (Fisher-Yates over rand): one MaskGIT order
|
|
425
|
+
export function randomOrder(n, rand = Math.random) {
|
|
426
|
+
const o = Array.from({ length: n }, (_, i) => i);
|
|
427
|
+
for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [o[i], o[j]] = [o[j], o[i]]; }
|
|
428
|
+
return o;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Codes [12] of one frame (lfm3_audio.session.sample_frame): the order split by the schedule; per pass one depth run
|
|
432
|
+
// on (hidden, codes so far, mask of the hidden codebooks) and each picked codebook sampled from its logits with the
|
|
433
|
+
// repetition penalty over its own column of history; all audio_eos_code when any code is.
|
|
434
|
+
// depth(hidden, codes BigInt64Array [12], masked Uint8Array [12]) → Float32Array [12 · codebook_size]
|
|
435
|
+
export async function sampleFrame(hidden, depth, { order, schedule = [4, 4, 4], codebook_size = 2017, audio_eos_code = 2016, sampling = {}, history = [], rand = Math.random }) {
|
|
436
|
+
const K = order.length;
|
|
437
|
+
if ([...order].sort((a, b) => a - b).some((v, i) => v !== i)) throw new Error(`MaskGIT order ${order} is not a permutation`);
|
|
438
|
+
const codes = new BigInt64Array(K), masked = new Uint8Array(K).fill(1);
|
|
439
|
+
let at = 0;
|
|
440
|
+
for (const n of schedule) {
|
|
441
|
+
const pick = order.slice(at, at + n); at += n;
|
|
442
|
+
const logits = await depth(hidden, codes, masked);
|
|
443
|
+
for (const k of pick) {
|
|
444
|
+
const col = history.map((f) => f[k]);
|
|
445
|
+
codes[k] = BigInt(sampleToken(logits.subarray(k * codebook_size, (k + 1) * codebook_size), sampling, col, [], rand));
|
|
446
|
+
}
|
|
447
|
+
for (const k of pick) masked[k] = 0;
|
|
448
|
+
}
|
|
449
|
+
const frame = Array.from(codes, Number);
|
|
450
|
+
return frame.includes(audio_eos_code) ? frame.fill(audio_eos_code) : frame;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Speech sampling of generate()'s audio_* options, as lfm3_audio's sample_token reads them: temperature 0 (or none
|
|
454
|
+
// and no top-k) is argmax; a top-k without a temperature samples at temperature 1.
|
|
455
|
+
export function audioSampling({ audio_temperature, audio_top_k, audio_repetition_penalty } = {}) {
|
|
456
|
+
const greedy = audio_temperature === 0 || (audio_temperature == null && !audio_top_k);
|
|
457
|
+
return { do_sample: !greedy, temperature: audio_temperature ?? 1, top_k: audio_top_k ?? 0, repetition_penalty: audio_repetition_penalty ?? 1 };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// liquid-audio's inverse STFT with "same" padding (lfm2_audio.infer.istft_same_padding), fed row by row: each row is
|
|
461
|
+
// codec_decoder.onnx's [log-magnitude (n_fft/2+1), phase (n_fft/2+1)]; irfft, periodic Hann (float64), overlap-add,
|
|
462
|
+
// divide by the summed window², trim (n_fft − hop)/2 at both ends. push() returns the samples no later row can change;
|
|
463
|
+
// end() the rest. Offline and streamed output are the same samples.
|
|
464
|
+
export class Istft {
|
|
465
|
+
constructor(n_fft = 1280, hop = 320) {
|
|
466
|
+
this.N = n_fft; this.hop = hop; this.bins = n_fft / 2 + 1; this.pad = (n_fft - hop) >> 1;
|
|
467
|
+
this.win = Float64Array.from({ length: n_fft }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n_fft));
|
|
468
|
+
this.cos = Float64Array.from({ length: n_fft }, (_, i) => Math.cos((2 * Math.PI * i) / n_fft));
|
|
469
|
+
this.sin = Float64Array.from({ length: n_fft }, (_, i) => Math.sin((2 * Math.PI * i) / n_fft));
|
|
470
|
+
this.wave = new Float64Array(0); this.env = new Float64Array(0); this.rows = 0; this.emitted = 0;
|
|
471
|
+
}
|
|
472
|
+
#grow(size) {
|
|
473
|
+
if (this.wave.length >= size) return;
|
|
474
|
+
const n = Math.max(size, this.wave.length * 2), w = new Float64Array(n), e = new Float64Array(n);
|
|
475
|
+
w.set(this.wave); e.set(this.env); this.wave = w; this.env = e;
|
|
476
|
+
}
|
|
477
|
+
// rows: Float32Array of whole rows (n_fft + 2 each)
|
|
478
|
+
push(rows) {
|
|
479
|
+
const { N, bins, hop } = this, width = 2 * bins, count = rows.length / width;
|
|
480
|
+
this.#grow((this.rows + count - 1) * hop + N);
|
|
481
|
+
const re = new Float64Array(bins), im = new Float64Array(bins);
|
|
482
|
+
for (let r = 0; r < count; r++) {
|
|
483
|
+
const base = r * width, o = (this.rows + r) * hop;
|
|
484
|
+
for (let k = 0; k < bins; k++) { const m = Math.exp(rows[base + k]), ph = rows[base + bins + k]; re[k] = m * Math.cos(ph); im[k] = m * Math.sin(ph); }
|
|
485
|
+
for (let n = 0; n < N; n++) {
|
|
486
|
+
// numpy irfft: the imaginary parts of bins 0 and N/2 are dropped
|
|
487
|
+
let x = re[0] + (n & 1 ? -re[bins - 1] : re[bins - 1]);
|
|
488
|
+
for (let k = 1, a = n; k < bins - 1; k++, a = (a + n) % N) x += 2 * (re[k] * this.cos[a] - im[k] * this.sin[a]);
|
|
489
|
+
this.wave[o + n] += (x / N) * this.win[n];
|
|
490
|
+
this.env[o + n] += this.win[n] * this.win[n];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this.rows += count;
|
|
494
|
+
return this.#take(this.rows * hop);
|
|
495
|
+
}
|
|
496
|
+
end() { return this.rows ? this.#take((this.rows - 1) * this.hop + this.N - this.pad) : new Float32Array(0); }
|
|
497
|
+
#take(upTo) {
|
|
498
|
+
const from = this.pad + this.emitted, to = Math.max(from, upTo);
|
|
499
|
+
const out = new Float32Array(to - from);
|
|
500
|
+
for (let i = 0; i < out.length; i++) out[i] = this.wave[from + i] / this.env[from + i];
|
|
501
|
+
this.emitted += out.length;
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Streams one utterance's frames to PCM: decode(final) runs the codec on the frames not yet decoded plus up to
|
|
507
|
+
// CODEC_CONTEXT_FRAMES before them, keeps the new frames' rows and pushes them through the inverse STFT.
|
|
508
|
+
// codec(codes BigInt64Array [12 · F], F) → Float32Array [6F · (n_fft + 2)]
|
|
509
|
+
export class SpeechStream {
|
|
510
|
+
constructor(codec, { num_codebooks = 12, frame_stack = 6, n_fft = 1280, hop = 320, context = CODEC_CONTEXT_FRAMES } = {}) {
|
|
511
|
+
Object.assign(this, { codec, K: num_codebooks, stack: frame_stack, width: n_fft + 2, context });
|
|
512
|
+
this.istft = new Istft(n_fft, hop); this.frames = []; this.done = 0;
|
|
513
|
+
}
|
|
514
|
+
push(frame) { this.frames.push(frame); }
|
|
515
|
+
get pending() { return this.frames.length - this.done; }
|
|
516
|
+
async decode(final = false) {
|
|
517
|
+
const parts = [];
|
|
518
|
+
if (this.pending) {
|
|
519
|
+
const start = Math.max(0, this.done - this.context), F = this.frames.length - start, K = this.K;
|
|
520
|
+
// audio_codes [1, 12, F]: codebook-major
|
|
521
|
+
const codes = new BigInt64Array(K * F);
|
|
522
|
+
for (let f = 0; f < F; f++) for (let k = 0; k < K; k++) codes[k * F + f] = BigInt(this.frames[start + f][k]);
|
|
523
|
+
const rows = await this.codec(codes, F);
|
|
524
|
+
parts.push(this.istft.push(rows.subarray((this.done - start) * this.stack * this.width)));
|
|
525
|
+
this.done = this.frames.length;
|
|
526
|
+
}
|
|
527
|
+
if (final) parts.push(this.istft.end());
|
|
528
|
+
return concat(parts);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── The engine ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
export class Lfm3Omni {
|
|
534
|
+
// opts: baseUrl (the repo layout) or files { 'onnx/decoder_q4f16.onnx': URL | Blob | ArrayBuffer | Uint8Array, … };
|
|
535
|
+
// ort (onnxruntime-web/webgpu module), transformers (for PreTrainedTokenizer) or tokenizer; device 'webgpu';
|
|
536
|
+
// onProgress({ status: 'download' | 'cache', file, loaded, total }) while a file arrives, then 'session' per graph;
|
|
537
|
+
// cacheName (CACHE_NAME; null: no Cache Storage) and force (download again, replacing the stored copies);
|
|
538
|
+
// speech: also load the speech-out graphs now (else the first generate() with audio output loads them)
|
|
539
|
+
static async load(opts = {}) {
|
|
540
|
+
const { baseUrl = LFM3_OMNI_2B.baseUrl, files = {}, device = 'webgpu', onProgress = () => {}, cacheName = CACHE_NAME, force = false } = opts;
|
|
541
|
+
const src = (name) => files[name] ?? new URL(name, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href;
|
|
542
|
+
const cache = cacheName && globalThis.caches ? await caches.open(cacheName) : null;
|
|
543
|
+
const bytes = async (name) => {
|
|
544
|
+
const s = src(name);
|
|
545
|
+
if (s instanceof Uint8Array) return s;
|
|
546
|
+
if (s instanceof ArrayBuffer) return new Uint8Array(s);
|
|
547
|
+
if (s instanceof Blob) return new Uint8Array(await s.arrayBuffer());
|
|
548
|
+
return fetchCached(s, { cache, force, onProgress: (e) => onProgress({ ...e, file: name }) });
|
|
549
|
+
};
|
|
550
|
+
const json = async (name) => JSON.parse(new TextDecoder().decode(await bytes(name)));
|
|
551
|
+
const text = async (name) => new TextDecoder().decode(await bytes(name));
|
|
552
|
+
|
|
553
|
+
onProgress({ status: 'config' });
|
|
554
|
+
const [config, gen, processor] = await Promise.all([json('config.json'), json('generation_config.json').catch(() => ({})), json('processor_config.json')]);
|
|
555
|
+
if (config.model_type !== 'lfm3_omni') throw new Error(`not an LFM3-Omni config: ${config.model_type}`);
|
|
556
|
+
let tokenizer = opts.tokenizer;
|
|
557
|
+
if (!tokenizer) {
|
|
558
|
+
onProgress({ status: 'tokenizer' });
|
|
559
|
+
const tf = opts.transformers ?? (await import(TRANSFORMERS_URL));
|
|
560
|
+
const [tj, tc, tpl] = await Promise.all([json('tokenizer.json'), json('tokenizer_config.json'), text('chat_template.jinja')]);
|
|
561
|
+
tokenizer = new tf.PreTrainedTokenizer(tj, { ...tc, chat_template: tc.chat_template ?? tpl });
|
|
562
|
+
}
|
|
563
|
+
const ort = opts.ort ?? (await import(ORT_WEB_URL));
|
|
564
|
+
// the graph and its external data, both read here (byte progress); the session is built from the bytes, which
|
|
565
|
+
// are dropped once onnxruntime holds the weights
|
|
566
|
+
const graph = async (role) => {
|
|
567
|
+
const file = LFM3_OMNI_2B.graphs[role] ?? LFM3_OMNI_2B.speechGraphs[role];
|
|
568
|
+
const model = await bytes(file), data = await bytes(file + '_data');
|
|
569
|
+
onProgress({ status: 'session', file });
|
|
570
|
+
return { model, data, dataPath: file.split('/').pop() + '_data' };
|
|
571
|
+
};
|
|
572
|
+
const sessionOpts = { device };
|
|
573
|
+
const vision = await createSession(ort, await graph('vision'), sessionOpts);
|
|
574
|
+
const audio = await createSession(ort, await graph('audio'), sessionOpts);
|
|
575
|
+
const decoder = await OrtLfm3Decoder.create(ort, await graph('decoder'), sessionOpts, config.text_config);
|
|
576
|
+
const m = new Lfm3Omni({ ort, tokenizer, config, gen, processor, vision, audio, decoder });
|
|
577
|
+
// the speech graphs, built when first needed from the same file source and cache
|
|
578
|
+
m.loadSpeechGraphs = async () => ({ depth: await createSession(ort, await graph('depth'), sessionOpts), codec: await createSession(ort, await graph('codec'), sessionOpts) });
|
|
579
|
+
if (opts.speech) await m.loadSpeech();
|
|
580
|
+
onProgress({ status: 'ready' });
|
|
581
|
+
return m;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The depth and codec sessions (speech out); loaded once
|
|
585
|
+
async loadSpeech() {
|
|
586
|
+
if (!this.depth) {
|
|
587
|
+
if (!this.loadSpeechGraphs) throw new Error('no speech graphs: load the engine with Lfm3Omni.load()');
|
|
588
|
+
this.speechLoading ??= this.loadSpeechGraphs().then(({ depth, codec }) => { this.depth = depth; this.codec = codec; });
|
|
589
|
+
await this.speechLoading;
|
|
590
|
+
}
|
|
591
|
+
return this;
|
|
592
|
+
}
|
|
593
|
+
// depth_decoder.onnx: logits [12 · codebook_size] of one frame
|
|
594
|
+
async depthLogits(hidden, codes, masked) {
|
|
595
|
+
const T = this.ort.Tensor, K = codes.length;
|
|
596
|
+
const out = await this.depth.run({ hidden_states: new T('float32', hidden, [1, hidden.length]), codes: new T('int64', codes, [1, K]), masked: new T('bool', masked, [1, K]) });
|
|
597
|
+
const l = Float32Array.from(out.logits.data); out.logits.dispose?.();
|
|
598
|
+
return l;
|
|
599
|
+
}
|
|
600
|
+
// codec_decoder.onnx: stft_features [6F · (n_fft + 2)] of frames (codebook-major codes [12 · F])
|
|
601
|
+
async codecRows(codes, F) {
|
|
602
|
+
const out = await this.codec.run({ audio_codes: new this.ort.Tensor('int64', codes, [1, this.speech.num_codebooks, F]) });
|
|
603
|
+
const r = Float32Array.from(out.stft_features.data); out.stft_features.dispose?.();
|
|
604
|
+
return r;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Every file load() reads, relative to baseUrl (with speech: also the speech-out graphs)
|
|
608
|
+
static files({ speech = false } = {}) {
|
|
609
|
+
const graphs = { ...LFM3_OMNI_2B.graphs, ...(speech ? LFM3_OMNI_2B.speechGraphs : {}) };
|
|
610
|
+
return [...LFM3_OMNI_2B.json, 'chat_template.jinja', ...Object.values(graphs).flatMap((g) => [g, g + '_data'])];
|
|
611
|
+
}
|
|
612
|
+
// Stored copies of this model's files under baseUrl: [{ file, url, bytes }] (bytes from the stored length)
|
|
613
|
+
static async cached({ baseUrl = LFM3_OMNI_2B.baseUrl, cacheName = CACHE_NAME } = {}) {
|
|
614
|
+
if (!globalThis.caches) return [];
|
|
615
|
+
const cache = await caches.open(cacheName), out = [];
|
|
616
|
+
for (const file of Lfm3Omni.files({ speech: true })) {
|
|
617
|
+
const url = new URL(file, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href, r = await cache.match(url);
|
|
618
|
+
if (r) out.push({ file, url, bytes: Number(r.headers.get('content-length')) || null });
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
// Deletes this model's stored files by name (other entries in the cache stay); resolves to the files deleted
|
|
623
|
+
static async deleteCache({ baseUrl = LFM3_OMNI_2B.baseUrl, cacheName = CACHE_NAME } = {}) {
|
|
624
|
+
if (!globalThis.caches) return [];
|
|
625
|
+
const cache = await caches.open(cacheName), gone = [];
|
|
626
|
+
for (const { file, url } of await Lfm3Omni.cached({ baseUrl, cacheName })) if (await cache.delete(url)) gone.push(file);
|
|
627
|
+
return gone;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
constructor({ ort, tokenizer, config, gen, processor, vision, audio, decoder }) {
|
|
631
|
+
Object.assign(this, { ort, tokenizer, config, generationConfig: gen, vision, audio, decoder });
|
|
632
|
+
// true from load until dispose
|
|
633
|
+
this.ready = !!decoder;
|
|
634
|
+
this.hidden = config.text_config.hidden_size;
|
|
635
|
+
// ids per prefill run (the model card's cap; a test lowers it to cut placeholders across runs)
|
|
636
|
+
this.prefillChunk = PREFILL_CHUNK;
|
|
637
|
+
this.mel = melConfig(processor.feature_extractor);
|
|
638
|
+
this.melFilters = melFilters(this.mel);
|
|
639
|
+
this.image = imageConfig(processor.image_processor);
|
|
640
|
+
const t = (k, d) => config[k] ?? d;
|
|
641
|
+
this.tokens = {
|
|
642
|
+
audio: t('audio_token_id', 125031), audio_out: t('audio_out_token_id', 125032), image: t('image_token_id', 125030),
|
|
643
|
+
video: t('video_token_id', 125029), audio_start: t('audio_start_token_id', 125012), text_end: t('text_end_token_id', 125014),
|
|
644
|
+
mixed_start: t('mixed_start_token_id', 125015), eos: config.text_config.eos_token_id,
|
|
645
|
+
think_end: tokenizer.convert_tokens_to_ids?.('</think>') ?? null,
|
|
646
|
+
};
|
|
647
|
+
// speech out (lfm3_audio.config), and the codec's STFT (audio_tokenizer/config.json's values for this checkpoint)
|
|
648
|
+
this.speech = { num_codebooks: t('num_codebooks', 12), codebook_size: t('codebook_size', 2017), audio_eos_code: t('audio_eos_code', 2016),
|
|
649
|
+
maskgit_schedule: t('maskgit_schedule', [4, 4, 4]), interleaved_n_text: t('interleaved_n_text', 6), interleaved_n_audio: t('interleaved_n_audio', 12),
|
|
650
|
+
frame_stack: 6, n_fft: 1280, hop: 320, sampling_rate: 24000 };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// The chat template's text and ids (placeholders expanded) of messages; media come back for the encoders.
|
|
654
|
+
async prepare(messages, { images = [], audios = [], enable_thinking = false, tools, add_generation_prompt = true, max_audio_seconds = MAX_AUDIO_SECONDS } = {}) {
|
|
655
|
+
const m = splitMedia(messages, images, audios);
|
|
656
|
+
m.audios.forEach((wave, i) => checkAudio(wave, i, max_audio_seconds, this.mel.sampling_rate));
|
|
657
|
+
const prompt = this.tokenizer.apply_chat_template(m.messages, { tokenize: false, add_generation_prompt, enable_thinking, ...(tools ? { tools } : {}) });
|
|
658
|
+
let ids = Array.from(this.tokenizer.encode(prompt, { add_special_tokens: false }), Number);
|
|
659
|
+
const pics = [];
|
|
660
|
+
for (const img of m.images) pics.push(preprocessImage(await pixelsOf(img), this.image));
|
|
661
|
+
const mels = m.audios.map((wave) => logMel(wave, this.mel, this.melFilters));
|
|
662
|
+
ids = expandPlaceholders(ids, this.tokens.audio, mels.map((x) => speechTokens(x.dims[1], this.mel)));
|
|
663
|
+
ids = expandPlaceholders(ids, this.tokens.image, pics.map((x) => x.tokens));
|
|
664
|
+
return { prompt, ids, pics, mels };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// image_features [Σ tokens × hidden] of preprocessed images: one vision run per image, in prompt order
|
|
668
|
+
async encodeImages(pics) {
|
|
669
|
+
const parts = [];
|
|
670
|
+
for (const p of pics) {
|
|
671
|
+
const out = await this.vision.run({ pixel_values: new this.ort.Tensor('float32', p.pixelValues, p.dims) });
|
|
672
|
+
const f = out.image_features;
|
|
673
|
+
if (f.dims[0] !== p.tokens) throw new Error(`grid ${p.grid} gave ${f.dims[0]} image features, expected ${p.tokens}`);
|
|
674
|
+
parts.push(Float32Array.from(f.data)); f.dispose?.();
|
|
675
|
+
}
|
|
676
|
+
return concat(parts);
|
|
677
|
+
}
|
|
678
|
+
// audio_features [Σ tokens × hidden] of log-mel clips: one audio_encoder run per clip
|
|
679
|
+
async encodeAudio(mels) {
|
|
680
|
+
const parts = [];
|
|
681
|
+
for (const m of mels) {
|
|
682
|
+
const out = await this.audio.run({ input_features: new this.ort.Tensor('float32', m.data, [1, ...m.dims]) });
|
|
683
|
+
const f = out.audio_features;
|
|
684
|
+
if (f.dims[0] !== speechTokens(m.dims[1], this.mel)) throw new Error(`${m.dims[1]} mel frames gave ${f.dims[0]} audio features`);
|
|
685
|
+
parts.push(Float32Array.from(f.data)); f.dispose?.();
|
|
686
|
+
}
|
|
687
|
+
return concat(parts);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Prefill ids in chunks of ≤ prefillChunk; each chunk takes the feature rows of its own placeholders.
|
|
691
|
+
async prefill(ids, side) {
|
|
692
|
+
const H = this.hidden, at = { image_features: 0, audio_features: 0 }, token = { image_features: this.tokens.image, audio_features: this.tokens.audio };
|
|
693
|
+
let last;
|
|
694
|
+
for (let s = 0; s < ids.length; s += this.prefillChunk) {
|
|
695
|
+
const chunk = ids.slice(s, s + this.prefillChunk), feed = {};
|
|
696
|
+
for (const k of Object.keys(at)) {
|
|
697
|
+
const n = chunk.filter((t) => t === token[k]).length;
|
|
698
|
+
feed[k] = side[k] ? side[k].subarray(at[k] * H, (at[k] + n) * H) : undefined;
|
|
699
|
+
at[k] += n;
|
|
700
|
+
}
|
|
701
|
+
last = await this.decoder.step(chunk, feed);
|
|
702
|
+
}
|
|
703
|
+
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`);
|
|
704
|
+
return last;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Answer to messages, with transformers.js-style options (max_new_tokens, do_sample, temperature, top_k, top_p,
|
|
708
|
+
// min_p, repetition_penalty, enable_thinking, tools, seed). Resolves { text, stopReason, tokens, reasoning, content }:
|
|
709
|
+
// text is the raw answer (special tokens skipped; <think> and <tool_call> markup kept, the caller parses it),
|
|
710
|
+
// stopReason 'eos' | 'length' | 'aborted'. Streaming: onText(delta) with each new piece of text (held back while
|
|
711
|
+
// a token ends inside a UTF-8 character), onToken(id), and a transformers.js streamer (put([[id]]), end()). signal
|
|
712
|
+
// (AbortSignal) is read before every decode step; an abort resolves, it does not throw.
|
|
713
|
+
async generate(messages, opts = {}) {
|
|
714
|
+
const { max_new_tokens = 1024, signal, onText, onToken, streamer, seed, output_modalities = ['text'] } = opts;
|
|
715
|
+
if (output_modalities.includes('audio')) {
|
|
716
|
+
if (!output_modalities.includes('text')) throw new Error("LFM3-Omni speaks in interleaved mode: output_modalities ['text', 'audio']");
|
|
717
|
+
return this.#generateSpeech(messages, opts);
|
|
718
|
+
}
|
|
719
|
+
const sampling = { do_sample: opts.do_sample ?? false, temperature: opts.temperature ?? 1, top_k: opts.top_k ?? 0,
|
|
720
|
+
top_p: opts.top_p ?? 1, min_p: opts.min_p ?? 0, repetition_penalty: opts.repetition_penalty ?? 1 };
|
|
721
|
+
const rand = seed === undefined ? Math.random : mulberry32(seed);
|
|
722
|
+
const T = this.tokens, tokens = [], history = [];
|
|
723
|
+
const suppress = [T.audio, T.audio_out, T.image, T.video, T.mixed_start, T.audio_start];
|
|
724
|
+
let stopReason = 'length', sent = 0;
|
|
725
|
+
const emit = () => {
|
|
726
|
+
const full = this.tokenizer.decode(tokens, { skip_special_tokens: true });
|
|
727
|
+
if (full.length > sent && !full.endsWith('\uFFFD')) { onText(full.slice(sent)); sent = full.length; }
|
|
728
|
+
};
|
|
729
|
+
if (signal?.aborted) return { text: '', stopReason: 'aborted', tokens, reasoning: '', content: '' };
|
|
730
|
+
const { ids, pics, mels } = await this.prepare(messages, opts);
|
|
731
|
+
const side = { image_features: pics.length ? await this.encodeImages(pics) : undefined, audio_features: mels.length ? await this.encodeAudio(mels) : undefined };
|
|
732
|
+
try {
|
|
733
|
+
let { logits } = await this.prefill(ids, side);
|
|
734
|
+
// TEXT mode of lfm3_audio's slot machine; the speech layer adds AUDIO / INTERLEAVED slots here, read the step's
|
|
735
|
+
// hidden state, and feeds frames back as <|audio_out|> with audio_out_codes
|
|
736
|
+
for (let step = 0; step < max_new_tokens; step++) {
|
|
737
|
+
if (signal?.aborted) { stopReason = 'aborted'; break; }
|
|
738
|
+
const tok = sampleToken(logits, sampling, history, suppress, rand);
|
|
739
|
+
if (tok === T.eos || tok === T.text_end) { stopReason = 'eos'; break; }
|
|
740
|
+
tokens.push(tok); history.push(tok); if (history.length > 32) history.shift();
|
|
741
|
+
streamer?.put?.([[BigInt(tok)]]); onToken?.(tok);
|
|
742
|
+
if (onText) emit();
|
|
743
|
+
if (step === max_new_tokens - 1) break;
|
|
744
|
+
({ logits } = await this.decoder.step([tok]));
|
|
745
|
+
}
|
|
746
|
+
} finally { this.decoder.reset(); streamer?.end?.(); }
|
|
747
|
+
const text = this.tokenizer.decode(tokens, { skip_special_tokens: true });
|
|
748
|
+
if (onText && text.length > sent) onText(text.slice(sent));
|
|
749
|
+
return { text, stopReason, tokens, ...this.decodeText(tokens) };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Interleaved text and speech (lfm3_audio.session.generate_ids from <|mixed_start|>). max_new_tokens counts slots,
|
|
753
|
+
// text tokens and frames alike. Text streams as in generate(); speech as onAudio(Float32Array 24 kHz) every
|
|
754
|
+
// audio_chunk_frames frames and at the end; signal is read before every slot. orders: a function returning one
|
|
755
|
+
// MaskGIT order per frame, or an array of them (a reference replay); default random from seed.
|
|
756
|
+
// Resolves { text, stopReason, tokens, reasoning, content, audio: Float32Array, audio_codes: frames, sampling_rate }.
|
|
757
|
+
async #generateSpeech(messages, opts) {
|
|
758
|
+
const { max_new_tokens = 1024, signal, onText, onToken, onAudio, streamer, seed, audio_chunk_frames = 12 } = opts;
|
|
759
|
+
await this.loadSpeech();
|
|
760
|
+
const g = this.generationConfig, S = this.speech, T = this.tokens;
|
|
761
|
+
const text = { do_sample: opts.do_sample ?? false, temperature: opts.temperature ?? 1, top_k: opts.top_k ?? 0,
|
|
762
|
+
top_p: opts.top_p ?? 1, min_p: opts.min_p ?? 0, repetition_penalty: opts.repetition_penalty ?? 1 };
|
|
763
|
+
const audio = audioSampling({ audio_temperature: opts.audio_temperature ?? g.audio_temperature ?? 0.4, audio_top_k: opts.audio_top_k ?? g.audio_top_k,
|
|
764
|
+
audio_repetition_penalty: opts.audio_repetition_penalty ?? g.audio_repetition_penalty ?? 1.2 });
|
|
765
|
+
const window = opts.audio_repetition_window ?? g.audio_repetition_window ?? 64;
|
|
766
|
+
const rand = seed === undefined ? Math.random : mulberry32(seed);
|
|
767
|
+
const orderOf = Array.isArray(opts.orders) ? ((i) => opts.orders[i]) : typeof opts.orders === 'function' ? opts.orders : () => randomOrder(S.num_codebooks, rand);
|
|
768
|
+
const msgs = messages.some((m) => m.role === 'system') ? messages : [{ role: 'system', content: SPEECH_SYSTEM_PROMPT }, ...messages];
|
|
769
|
+
const p = await this.prepare(msgs, { ...opts, enable_thinking: false });
|
|
770
|
+
const ids = [...p.ids, T.mixed_start];
|
|
771
|
+
const side = { image_features: p.pics.length ? await this.encodeImages(p.pics) : undefined, audio_features: p.mels.length ? await this.encodeAudio(p.mels) : undefined };
|
|
772
|
+
const suppress = [T.audio, T.audio_out, T.image, T.video];
|
|
773
|
+
const stream = new SpeechStream((c, F) => this.codecRows(c, F), { num_codebooks: S.num_codebooks, frame_stack: S.frame_stack, n_fft: S.n_fft, hop: S.hop });
|
|
774
|
+
const tokens = [], textIds = [], textHistory = [], history = [], frames = [], chunks = [];
|
|
775
|
+
let stopReason = 'length', sent = 0, mode = 'interleaved', slotsLeft = S.interleaved_n_text, speaking = false, textDone = false, nFrames = 0;
|
|
776
|
+
const emitText = () => {
|
|
777
|
+
if (!onText) return;
|
|
778
|
+
const full = this.tokenizer.decode(textIds, { skip_special_tokens: true });
|
|
779
|
+
if (full.length > sent && !full.endsWith('\uFFFD')) { onText(full.slice(sent)); sent = full.length; }
|
|
780
|
+
};
|
|
781
|
+
const flush = async (final) => { const pcm = await stream.decode(final); if (pcm.length) { chunks.push(pcm); onAudio?.(pcm); } };
|
|
782
|
+
try {
|
|
783
|
+
let { logits, hidden } = await this.prefill(ids, side);
|
|
784
|
+
for (let step = 0; step < max_new_tokens; step++) {
|
|
785
|
+
if (signal?.aborted) { stopReason = 'aborted'; break; }
|
|
786
|
+
const interleaving = mode === 'interleaved';
|
|
787
|
+
let next, codes, finished = false;
|
|
788
|
+
if (mode === 'audio' || (interleaving && speaking)) {
|
|
789
|
+
const frame = await sampleFrame(hidden, (h, c, m) => this.depthLogits(h, c, m), { order: orderOf(nFrames++), schedule: S.maskgit_schedule,
|
|
790
|
+
codebook_size: S.codebook_size, audio_eos_code: S.audio_eos_code, sampling: audio, history, rand });
|
|
791
|
+
if (frame[0] === S.audio_eos_code) mode = 'text';
|
|
792
|
+
else {
|
|
793
|
+
history.push(frame); if (history.length > window) history.shift();
|
|
794
|
+
frames.push(frame); tokens.push(T.audio_out); stream.push(frame);
|
|
795
|
+
if (stream.pending >= audio_chunk_frames) await flush(false);
|
|
796
|
+
}
|
|
797
|
+
next = T.audio_out; codes = BigInt64Array.from(frame, BigInt);
|
|
798
|
+
} else {
|
|
799
|
+
const tok = sampleToken(logits, text, textHistory, suppress, rand);
|
|
800
|
+
tokens.push(tok); textHistory.push(tok); if (textHistory.length > 32) textHistory.shift();
|
|
801
|
+
if (tok === T.text_end) { if (interleaving) textDone = speaking = true; else finished = true; }
|
|
802
|
+
else if (tok === T.eos) finished = true;
|
|
803
|
+
else if (!interleaving && tok === T.audio_start) mode = 'audio';
|
|
804
|
+
else if (!interleaving && tok === T.mixed_start) { mode = 'interleaved'; slotsLeft = S.interleaved_n_text; speaking = false; textDone = false; }
|
|
805
|
+
if (tok !== T.text_end && tok !== T.eos) { textIds.push(tok); streamer?.put?.([[BigInt(tok)]]); onToken?.(tok); emitText(); }
|
|
806
|
+
next = tok;
|
|
807
|
+
}
|
|
808
|
+
if (interleaving && !textDone && --slotsLeft === 0) { speaking = !speaking; slotsLeft = speaking ? S.interleaved_n_audio : S.interleaved_n_text; }
|
|
809
|
+
if (finished) { stopReason = 'eos'; break; }
|
|
810
|
+
if (step === max_new_tokens - 1) break;
|
|
811
|
+
({ logits, hidden } = await this.decoder.step([next], codes ? { audio_out_codes: codes } : {}));
|
|
812
|
+
}
|
|
813
|
+
if (stopReason !== 'aborted') await flush(true);
|
|
814
|
+
} finally { this.decoder.reset(); streamer?.end?.(); }
|
|
815
|
+
const all = this.tokenizer.decode(textIds, { skip_special_tokens: true });
|
|
816
|
+
if (onText && all.length > sent) onText(all.slice(sent));
|
|
817
|
+
return { text: all, stopReason, tokens, ...this.decodeText(textIds), audio: concat(chunks), audio_codes: frames, sampling_rate: S.sampling_rate };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Greedy ids after prompt ids (the gate's comparison): no template, no stop on EOS unless asked.
|
|
821
|
+
async greedyIds(ids, side = {}, n = 32, { stopAtEos = true } = {}) {
|
|
822
|
+
const T = this.tokens, suppress = [T.audio, T.audio_out, T.image, T.video, T.mixed_start, T.audio_start], out = [];
|
|
823
|
+
try {
|
|
824
|
+
let { logits } = await this.prefill(ids, side);
|
|
825
|
+
for (let k = 0; k < n; k++) {
|
|
826
|
+
const tok = sampleToken(logits, {}, [], suppress);
|
|
827
|
+
out.push(tok);
|
|
828
|
+
if (k === n - 1 || (stopAtEos && (tok === T.eos || tok === T.text_end))) break;
|
|
829
|
+
({ logits } = await this.decoder.step([tok]));
|
|
830
|
+
}
|
|
831
|
+
} finally { this.decoder.reset(); }
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// { content, reasoning }: the answer after </think> (special tokens skipped), and what the think block held
|
|
836
|
+
decodeText(tokens) {
|
|
837
|
+
const T = this.tokens, k = T.think_end == null ? -1 : tokens.indexOf(T.think_end);
|
|
838
|
+
const dec = (t) => this.tokenizer.decode(t, { skip_special_tokens: true });
|
|
839
|
+
if (k < 0) return { content: dec(tokens), reasoning: '' };
|
|
840
|
+
return { content: dec(tokens.slice(k + 1)).trimStart(), reasoning: dec(tokens.slice(0, k)).replace(/^\s*<think>/, '').trim() };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async dispose() {
|
|
844
|
+
this.ready = false;
|
|
845
|
+
await this.decoder.dispose(); await this.vision.release(); await this.audio.release();
|
|
846
|
+
if (this.depth) { await this.depth.release(); await this.codec.release(); this.depth = this.codec = null; }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function concat(parts) {
|
|
851
|
+
const out = new Float32Array(parts.reduce((a, p) => a + p.length, 0));
|
|
852
|
+
let o = 0; for (const p of parts) { out.set(p, o); o += p.length; }
|
|
853
|
+
return out;
|
|
854
|
+
}
|
package/ggml/dist/BUILD.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
because diskformer.naklitechie.com and the HF Space serve them and their CI has no Emscripten; the npm package ships them
|
|
5
5
|
as `diskformer/llama`.
|
|
6
6
|
|
|
7
|
-
Built from the fork `NakliTechie/llama.cpp` branch `webgpu-diskformer` at `
|
|
7
|
+
Built from the fork `NakliTechie/llama.cpp` branch `webgpu-diskformer` at `5ac033a52` (upstream master of 2026-10-22 merged in; head size 512 flash attention on the GPU; a thread-free log in wasm; DeepSeek-V4.1 from draft PR 30817 with WebGPU shaders for its ops), Emscripten 6.0.13 (Homebrew),
|
|
8
8
|
emdawnwebgpu from the fork's `emdawnwebgpu_pkg`, all C++ with `-fwasm-exceptions`. Rebuild (copies here as a
|
|
9
9
|
post-build step):
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ post-build step):
|
|
|
12
12
|
|
|
13
13
|
sha256 (check a copy with `shasum -a 256`):
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
9c532bdddb66821d7be2dff2d2008f9a976ca7b497574fa2f1520b4f9ab9ff58 df-chat.wasm
|
|
16
16
|
0f8a1a7e3eb75ef8f999d553d2fc1a124c4de1b6c6edb597643486752bcdce08 df-chat.mjs
|
|
17
17
|
|
|
18
18
|
Update the fork commit and the checksums whenever the build changes.
|
package/ggml/dist/df-chat.wasm
CHANGED
|
Binary file
|
package/ggml/llama.js
CHANGED
|
@@ -52,8 +52,9 @@ export class Llama {
|
|
|
52
52
|
|
|
53
53
|
// Loads a stored GGUF: budget (bytes of GPU memory, slots planned from it) or slots (experts per layer on the GPU).
|
|
54
54
|
// draft: a stored drafter GGUF (DFlash, DSpark, MTP...) for speculative decoding; replies stay greedy-identical.
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// extra: stored side files the model opens beside the GGUF, by name (DeepSeek-V4.1's Engram tables).
|
|
56
|
+
load({ name, budget = 0, slots = 0, ctx = 1024, env = {}, draft = null, extra = [] }, onPlan) {
|
|
57
|
+
return this.request({ type: 'load', opfsName: name, budget, slots, ctx, env, draft, extra }, ['loaded'], (d) => { if (d.type === 'plan' && onPlan) onPlan(d.plan); });
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// A reply through the model's chat template; greedy. Resolves { text, stats }. thinking: false renders the template
|
package/ggml/worker.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// sync access handles, straight into the wasm heap. A GGUF gets into OPFS by download (resumable, from a URL) or by
|
|
4
4
|
// copying a picked file; with opfs: false a picked File is read in place instead (slower).
|
|
5
5
|
//
|
|
6
|
-
// Messages in: load {opfsName | file, budget | slots, ctx, env, draft} · chat {messages, n, thinking} · complete {prompt, n} (the gate)
|
|
6
|
+
// Messages in: load {opfsName | file, budget | slots, ctx, env, draft, extra} · chat {messages, n, thinking} · complete {prompt, n} (the gate)
|
|
7
7
|
// · abort · download {url, name, size} · list · remove {name}
|
|
8
8
|
// Messages out: log · progress · plan · loaded · piece · done · downloaded · models · removed · error
|
|
9
9
|
import createModule from './dist/df-chat.mjs'; // built from ggml/build-wasm, see ggml/dist/BUILD.md
|
|
@@ -195,8 +195,14 @@ onmessage = async ({ data }) => {
|
|
|
195
195
|
// draft: a drafter GGUF in OPFS (DFlash, DSpark, MTP...) for speculative decoding; read whole at load, not paged
|
|
196
196
|
const draftFile = data.draft ? await (await ggufDir(false)).getFileHandle(data.draft).then((h) => h.getFile())
|
|
197
197
|
.catch(() => { throw new Error(`${data.draft} is not in OPFS; download the drafter first`); }) : null;
|
|
198
|
+
// extra: side files the model opens by name beside the GGUF (DeepSeek-V4.1's Engram tables), from OPFS
|
|
199
|
+
const extraFiles = [];
|
|
200
|
+
for (const name of data.extra || []) {
|
|
201
|
+
extraFiles.push(await (await ggufDir(false)).getFileHandle(name).then((h) => h.getFile())
|
|
202
|
+
.catch(() => { throw new Error(`${name} is not in OPFS; download it first`); }));
|
|
203
|
+
}
|
|
198
204
|
mod.FS.mkdir('/m');
|
|
199
|
-
mod.FS.mount(mod.WORKERFS, { files:
|
|
205
|
+
mod.FS.mount(mod.WORKERFS, { files: [...files, ...(draftFile ? [draftFile] : []), ...extraFiles] }, '/m');
|
|
200
206
|
if (draftFile) mod._df_set_draft(str('/m/' + draftFile.name));
|
|
201
207
|
const t0 = performance.now();
|
|
202
208
|
// a GPU budget (bytes) becomes slots through df_plan; else data.slots as given
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diskformer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
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",
|