anyformer 0.4.13 → 0.4.15

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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.15 — 2026-10-30
4
+
5
+ ### Fixed
6
+ - A caller that stops reading a reply early (a `break` out of `for await`, as after a tool call) without aborting
7
+ now stops the reply on `diskformer-onnx` and `transformers`: the back end aborts the engine call (a linked
8
+ AbortController for diskformer-onnx, the stopping criteria's interrupt for transformers.js) and waits for it before
9
+ the next generate() starts. Before, the engine kept generating, and the next reply ran on the same onnxruntime
10
+ sessions beside it: "failed to call OrtRun() … Failed to download data from buffer" or a hang of over 10 minutes in
11
+ Chrome (reported by NakliOS, root-caused by LocalMind). `diskformer-llama` already did this.
12
+
13
+ ### Changed
14
+ - diskformer pin 1fe19f8 → 7ffbea5 (v0.6.13: LFM3-Omni serves one reply at a time; changes only
15
+ `engines/lfm3_omni.js`).
16
+ - Browser tests `omni` and `omni-speech` break out of a reply (text, and spoken at its first speech chunk) without
17
+ aborting, then require the next reply to equal the text reference within 10 s. Passed 2026-10-30 (Chrome, this Mac,
18
+ local files), with `transformers` and the LFM3 1.7B `chat` check.
19
+
20
+ ## 0.4.14 — 2026-10-30
21
+
22
+ ### Added
23
+ - `diskformer-onnx`: LFM3-Omni speech out. `load(MODELS.chat.LFM3_OMNI_2B, { speech: true })` also fetches the depth
24
+ and codec decoders (515 MB, Cache Storage 'diskformer-models'); `generate(messages, { speech: true })` answers in
25
+ interleaved text and speech, and a chunk with new speech carries `audio` (24 kHz Float32 PCM since the last chunk)
26
+ and `sampleRate`. `seed`, `audioTemperature`, `audioRepetitionPenalty` and `audioRepetitionWindow` pass through.
27
+ Catalog flag `audioOut: true` on `MODELS.chat.LFM3_OMNI_2B`.
28
+ - Browser test `omni-speech` (Chrome, this Mac, 2026-10-30, local files): the reference speech clip in, a spoken reply
29
+ that names Mount Everest, 13.5 s of 24 kHz speech in 15 chunks over 9.3 s (first sound after 1.1 s), RMS 0.037
30
+ (the reference's 0.048); an abort from the first speech chunk ended the reply after 0.9 s of speech; a text reply
31
+ after it still equals the text reference. Code-level exactness is diskformer's own gate.
32
+
33
+ ### Changed
34
+ - diskformer pin 48f2c2a → 1fe19f8 (v0.6.12, LFM3-Omni speech out; changes only `engines/lfm3_omni.js`). The `omni`
35
+ test passes again on it (3/3 equal to the reference, streaming, abort, tool call).
36
+
3
37
  ## 0.4.13 — 2026-10-30
4
38
 
5
39
  ### Added
package/README.md CHANGED
@@ -55,7 +55,7 @@ back end's answer: yes with an estimate (GPU bytes, disk bytes, tok/s when measu
55
55
  | Back end | Runs | Models |
56
56
  |---|---|---|
57
57
  | `transformers` | transformers.js 4.3.2: text generation (Nemotron 3.5 Bolt E4B and LFM3 1.7B among them), image and audio chat (Gemma 4, Qwen3.5), embeddings, speech to text, detection, classification, depth, segmentation | ONNX repos in the onnx-community / Xenova layout |
58
- | `diskformer-onnx` | diskformer's hand engines on onnxruntime-web WebGPU, for ONNX models transformers.js has no class for | LFM3-Omni-2B: text, image and speech in, text out |
58
+ | `diskformer-onnx` | diskformer's hand engines on onnxruntime-web WebGPU, for ONNX models transformers.js has no class for | LFM3-Omni-2B: text, image and speech in; text or text and speech out |
59
59
  | `kohra` | kohra, text-diffusion over onnxruntime-web | MDLM and BD3LM repos tagged `text-diffusion`; Nemotron-Labs-Diffusion-3B |
60
60
  | `localmind` | LocalMind's hand-written WebGPU kernels (shipped in diskformer), the whole model on the GPU | any LFM2 / LFM2.5 GGUF; Gemma 4 E2B / E4B QAT-mobile; Ternary Bonsai 2 |
61
61
  | `wllama` | llama.cpp in WebAssembly: WebGPU when the budget holds the model, the CPU otherwise (no WebGPU needed) | any GGUF up to about 3.6 GB that no engine above covers; files over 2 GB are split into OPFS shards once |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyformer",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "One call to run a model in a browser tab: anyformer reads the Hub repo, probes the browser, and picks the back end (transformers.js, LocalMind's WebGPU kernels, diskformer, kohra) that fits the model's format, architecture and your GPU budget.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -11,7 +11,9 @@ import { SOURCES } from '../sources.js';
11
11
 
12
12
  // Keyed by config.model_type. graphs: the ONNX files the engine reads (each with its _data file); json: the small files.
13
13
  export const ONNX_ENGINES = {
14
- // LiquidAI LFM3-Omni-2B: text, image and speech in (16 kHz Float32Array, at most 30 s a clip), text out. transformers.js
14
+ // LiquidAI LFM3-Omni-2B: text, image and speech in (16 kHz Float32Array, at most 30 s a clip); text out, or text and
15
+ // speech interleaved (24 kHz, from the depth and codec decoders, 515 MB more, fetched on the first spoken reply or by
16
+ // load({ speech: true })). transformers.js
15
17
  // 4.3.2 has no lfm3_omni class; swap to transformers.js when it adds the model. diskformer's gate
16
18
  // (test/browser/lfm3-omni.html, onnxruntime-web 1.30.0): prompt ids and 32 greedy tokens equal Liquid's runner on
17
19
  // onnxruntime's CPU EP for text, image and speech, 3/3.
@@ -57,11 +59,12 @@ export const diskformerOnnxBackend = {
57
59
  return { ok: true, estimate: { gpuBytes: bytes, diskBytes: bytes, tokPerSec: null } };
58
60
  },
59
61
 
60
- async load(model, { onProgress, signal }) {
62
+ // speech: true also fetches the speech-out graphs now, so the first spoken reply does not wait for them.
63
+ async load(model, { speech = false, onProgress, signal }) {
61
64
  const e = ONNX_ENGINES[model.architecture];
62
65
  const mod = await import(`${diskformerOnnxBackend.source}/${e.engine}`);
63
66
  const baseUrl = diskformerOnnxBackend.url ? diskformerOnnxBackend.url(model) : `https://huggingface.co/${model.repo}/resolve/${model.revision || 'main'}/`;
64
- const engine = await mod[e.Engine].load({ baseUrl, onProgress: onProgress && ((p) => onProgress({ backend: 'diskformer-onnx', ...p })) });
67
+ const engine = await mod[e.Engine].load({ baseUrl, ...(speech && { speech: true }), onProgress: onProgress && ((p) => onProgress({ backend: 'diskformer-onnx', ...p })) });
65
68
  if (signal?.aborted) { await engine.dispose(); signal.throwIfAborted(); }
66
69
  return {
67
70
  task: 'text-generation',
@@ -73,22 +76,47 @@ export const diskformerOnnxBackend = {
73
76
  // or { data, width, height }) and { type: 'audio', audio } (16 kHz mono Float32Array). thinking: false (the
74
77
  // default, as on every back end here) answers directly. tools: the chat template's tool list; tool calls stay
75
78
  // raw text in the reply (LFM3 writes Qwen3-Coder XML), for the caller to parse. An abort ends the reply quietly.
76
- async *generate(messages, { maxNewTokens = 512, signal, thinking = false, tools, repetitionPenalty } = {}) {
79
+ // speech: true answers in interleaved text and speech (the engine adds its speech system turn when the messages
80
+ // have none, and thinks never): a chunk that carries new speech has `audio`, the 24 kHz Float32 PCM since the last
81
+ // chunk, and `sampleRate`; maxNewTokens then counts text tokens and speech frames (80 ms each) together. Speech is
82
+ // sampled (generation_config.json: temperature 0.4, repetition penalty 1.2 over 64 frames); audioTemperature,
83
+ // audioRepetitionPenalty, audioRepetitionWindow and seed override it.
84
+ async *generate(messages, { maxNewTokens = 512, signal, thinking = false, tools, repetitionPenalty, speech = false, seed,
85
+ audioTemperature, audioRepetitionPenalty, audioRepetitionWindow } = {}) {
77
86
  const queue = [];
78
87
  let wake = () => {}, done = false, failed = null, text = '', tokens = 0;
79
88
  const msgs = await media(messages);
80
- engine.generate(msgs, {
81
- max_new_tokens: maxNewTokens, do_sample: false, enable_thinking: thinking === true, signal,
82
- ...(tools && { tools }), ...(repetitionPenalty && { repetition_penalty: repetitionPenalty }),
89
+ const sampleRate = engine.speech?.sampling_rate ?? 24000;
90
+ // The engine reads this signal, linked to the caller's, so a caller that stops reading can end the reply too.
91
+ const stop = new AbortController();
92
+ const abort = () => stop.abort();
93
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
94
+ const job = engine.generate(msgs, {
95
+ max_new_tokens: maxNewTokens, do_sample: false, enable_thinking: thinking === true, signal: stop.signal,
96
+ ...(tools && { tools }), ...(repetitionPenalty && { repetition_penalty: repetitionPenalty }), ...(seed !== undefined && { seed }),
97
+ ...(speech && {
98
+ output_modalities: ['text', 'audio'],
99
+ onAudio: (pcm) => { queue.push({ text, tokens, audio: pcm, sampleRate }); wake(); },
100
+ ...(audioTemperature !== undefined && { audio_temperature: audioTemperature }),
101
+ ...(audioRepetitionPenalty !== undefined && { audio_repetition_penalty: audioRepetitionPenalty }),
102
+ ...(audioRepetitionWindow !== undefined && { audio_repetition_window: audioRepetitionWindow }),
103
+ }),
83
104
  onToken: () => { tokens++; },
84
105
  onText: (delta) => { text += delta; queue.push({ text, tokens }); wake(); },
85
106
  }).catch((err) => { failed = err; }).finally(() => { done = true; wake(); });
86
- while (true) {
87
- if (queue.length) { yield queue.shift(); continue; }
88
- if (done) break;
89
- await new Promise((r) => { wake = r; });
107
+ try {
108
+ while (true) {
109
+ if (queue.length) { yield queue.shift(); continue; }
110
+ if (done) break;
111
+ await new Promise((r) => { wake = r; });
112
+ }
113
+ if (failed) throw failed;
114
+ } finally {
115
+ signal?.removeEventListener('abort', abort);
116
+ // The caller stopped reading early (a break after a tool call): stop the reply, so the next call on this
117
+ // engine's sessions does not run beside it or queue behind it.
118
+ if (!done) { stop.abort(); await job; }
90
119
  }
91
- if (failed) throw failed;
92
120
  },
93
121
  dispose: () => engine.dispose(),
94
122
  };
@@ -177,14 +177,21 @@ async function* stream(lib, tokenizer, signal, run) {
177
177
  token_callback_function: () => { tokens++; },
178
178
  });
179
179
  const stop = new lib.InterruptableStoppingCriteria();
180
- signal?.addEventListener('abort', () => stop.interrupt(), { once: true });
181
- run(streamer, stop).catch((e) => { failed = e; }).finally(() => { done = true; wake(); });
182
- while (true) {
183
- if (queue.length) { yield queue.shift(); continue; }
184
- if (done) break;
185
- await new Promise((r) => { wake = r; });
180
+ const interrupt = () => stop.interrupt();
181
+ signal?.addEventListener('abort', interrupt, { once: true });
182
+ const job = run(streamer, stop).catch((e) => { failed = e; }).finally(() => { done = true; wake(); });
183
+ try {
184
+ while (true) {
185
+ if (queue.length) { yield queue.shift(); continue; }
186
+ if (done) break;
187
+ await new Promise((r) => { wake = r; });
188
+ }
189
+ if (failed) throw failed;
190
+ } finally {
191
+ signal?.removeEventListener('abort', interrupt);
192
+ // The caller stopped reading early: stop the reply so the next call on this model does not run beside it.
193
+ if (!done) { stop.interrupt(); await job; }
186
194
  }
187
- if (failed) throw failed;
188
195
  }
189
196
 
190
197
  // A text-only model in the multimodal layout: the causal-LM class over embed_tokens + decoder_model_merged.
package/src/load.js CHANGED
@@ -43,14 +43,14 @@ export async function plan(repoOrEntry, opts = {}) {
43
43
 
44
44
  export async function load(repoOrEntry, options = {}) {
45
45
  const [repo, opts] = entry(repoOrEntry, options);
46
- const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal } = opts;
46
+ const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, speech, onProgress, signal } = opts;
47
47
  let p;
48
48
  try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
49
49
  if (!p.chosen) throw noBackend(p);
50
50
  const backend = backends.find((b) => b.id === p.chosen);
51
51
  let session;
52
52
  try {
53
- session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, onProgress, signal });
53
+ session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, fetch: engineFetch, ple, speculative, speech, onProgress, signal });
54
54
  } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
55
55
  // Added onto serialize()'s object, not spread: a spread would read its getters once and freeze them.
56
56
  return Object.assign(serialize(session), { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p });
package/src/models.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // repo id; it carries the task and, where the back end needs one, the budget. DEFAULT is the smallest download that
3
3
  // passed. `test` names the page under test/browser/ that proves the entry; test/models.test.js checks it exists.
4
4
  // `diffusion: true` marks a text-diffusion model (the reply denoises in blocks); `experimental: true` marks an entry its
5
- // app shows as opt-in until that app's own checks pass.
5
+ // app shows as opt-in until that app's own checks pass; `audioOut: true` marks a model that can also answer in speech
6
+ // (generate({ speech: true })).
6
7
 
7
8
  const M = (repo, backend, task, test, extra = {}) => ({ repo, backend, task, test, ...extra });
8
9
 
@@ -23,7 +24,7 @@ const GEMMA4_26B_A4B = M('google/gemma-4-26B-A4B-it-qat-q4_0-gguf', 'diskformer'
23
24
  const LFM3_1_7B = M('LiquidAI/LFM3-1.7B-ONNX', 'transformers', 'text-generation', 'chat', { downloadBytes: 1.63e9 });
24
25
  // transformers.js 4.3.2 has no lfm3_omni class, so diskformer's hand engine runs it; swap the entry to transformers.js
25
26
  // when it adds the model.
26
- const LFM3_OMNI_2B = M('LiquidAI/LFM3-Omni-2B-ONNX', 'diskformer-onnx', 'any-to-any', 'omni', { downloadBytes: 2.14e9 });
27
+ const LFM3_OMNI_2B = M('LiquidAI/LFM3-Omni-2B-ONNX', 'diskformer-onnx', 'any-to-any', 'omni', { downloadBytes: 2.14e9, audioOut: true });
27
28
  const NEMOTRON_3_5_BOLT_E4B = M('danyalmalik/NVIDIA-Nemotron-3.5-Bolt-E4B-ONNX-WebGPU', 'transformers', 'text-generation', 'transformers', { budget: 5e9, downloadBytes: 4.94e9 });
28
29
  // A tech demo, not a gated model: 231 GB on disk (4 IQ1_M parts plus the 110.6 GB Engram side file), about 0.4 tok/s.
29
30
  // diskformer checked one hand-written prompt through complete(); generate() renders the GGUF's chat template, which no
@@ -60,7 +61,7 @@ export const MODELS = {
60
61
  NEMOTRON_3_5_BOLT_E4B,
61
62
  /** LiquidAI's LFM3 1.7B (dense), LiquidAI's own ONNX export; thinking off unless a request asks for it. */
62
63
  LFM3_1_7B,
63
- /** LiquidAI's LFM3-Omni-2B: text, image and speech in (16 kHz, at most 30 s a clip), text out (diskformer's engine). */
64
+ /** LiquidAI's LFM3-Omni-2B: text, image and speech in (16 kHz, at most 30 s a clip); text or text and speech out (diskformer's engine). */
64
65
  LFM3_OMNI_2B,
65
66
  /** DeepSeek V4.1 Flash (763B MoE), a tech demo on diskformer's llama.cpp path: 231 GB on disk, very slow. */
66
67
  DEEPSEEK_V4_1_FLASH,
package/src/sources.js CHANGED
@@ -9,6 +9,9 @@ export const PINS = {
9
9
  // v0.3.1: seeded block-KV graphs (Nemotron-Labs-Diffusion-3B), chatFormat, split external data; download progress
10
10
  // (progress_callback) and the graph files cached in Cache Storage 'kohra-models'.
11
11
  kohra: 'bf95d44c3e3cfc001201baa03fa121c2bf9c7d73',
12
+ // v0.6.13 (7ffbea5): LFM3-Omni serves one reply at a time; overlapping runs broke onnxruntime-web WebGPU.
13
+ // v0.6.12 (1fe19f8): LFM3-Omni speech out (interleaved text and 24 kHz speech; depth and codec decoders). Changes
14
+ // only engines/lfm3_omni.js; its text, image and speech-in gate (3/3) is unchanged.
12
15
  // 48f2c2a (main after v0.6.9): engines/lfm3_omni.js, LFM3-Omni-2B on onnxruntime-web 1.30.0 (text, image and speech
13
16
  // in; text out; files in Cache Storage 'diskformer-models'). Adds files only: no other engine or ggml/ changed.
14
17
  // v0.6.9: llama.load({ extra }) mounts side files beside the GGUF (DeepSeek V4.1 Flash's Engram tables).
@@ -16,7 +19,7 @@ export const PINS = {
16
19
  // Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/, which loads split GGUFs
17
20
  // and drafters, and reads past 2 GiB (Nemotron 3.5 Bolt E4B needs v0.6.8 or later). The hand engines' ingest
18
21
  // streams the GGUF header with progress and resumes a dropped range.
19
- diskformer: '48f2c2a2f5bf59b16db8f3d57653e7c44705ce6c',
22
+ diskformer: '7ffbea54a5f09494e1f6c970cce7c4c7b1b8460d',
20
23
  };
21
24
 
22
25
  const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;