diskformer 0.6.11 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.12 (2026-10-30)
4
+ - LFM3-Omni-2B speaks: `generate(messages, { output_modalities: ['text', 'audio'], onAudio })` runs Liquid's
5
+ interleaved mode (6 text tokens, 12 speech frames in turn), the depth decoder's MaskGIT (12 codes a frame) and the
6
+ codec decoder, then an inverse STFT to 24 kHz Float32 PCM, streamed about once a second. Options `audio_temperature`
7
+ (0.4), `audio_top_k`, `audio_repetition_penalty` (1.2), `audio_repetition_window` (64), `audio_chunk_frames`, `seed`;
8
+ `load({ speech: true })` fetches the two speech graphs (depth_decoder 336 MB, codec_decoder 179 MB, fp32) up front,
9
+ else the first speech call does. Text-only output is unchanged and stays the default.
10
+ - Gate `test/browser/lfm3-omni-speech.html`: text identical to Liquid's runner slot for slot; speech codes exact for
11
+ the first frames, then fp16 near-ties on WebGPU flip a code and later frames differ (M4 Pro: frames 0-6 exact), so
12
+ the gate asks for 4 exact frames, the same length and RMS within 10%; a sampled 13.5 s answer took 9.6 s, first
13
+ audio after 2.3 s (speech prompt included). The inverse STFT matches numpy to 5e-10 of peak. `--wav` writes the
14
+ replies for listening.
15
+
3
16
  ## 0.6.11 (2026-10-30)
4
17
  - Republishes 0.6.10's tagged files. npm 0.6.10 was packed from a working tree that held uncommitted, unchecked
5
18
  speech-output work in `engines/lfm3_omni.js` (854 lines against the tag's 628); 0.6.11 carries the tag's engine.
@@ -28,9 +28,14 @@
28
28
  *
29
29
  * Text slots follow lfm3_audio.session.generate_ids in TEXT mode: the placeholders (<|audio|>, <|audio_out|>,
30
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.
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.
34
39
  *
35
40
  * Model files go to Cache Storage ('diskformer-models', keyed by URL) on the first load and come from there later;
36
41
  * load({ force: true }) downloads again, Lfm3Omni.deleteCache({ baseUrl }) removes this model's entries by name.
@@ -46,6 +51,8 @@ export const LFM3_OMNI_2B = {
46
51
  repo: 'LiquidAI/LFM3-Omni-2B-ONNX',
47
52
  baseUrl: 'https://huggingface.co/LiquidAI/LFM3-Omni-2B-ONNX/resolve/main/',
48
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' },
49
56
  json: ['config.json', 'generation_config.json', 'processor_config.json', 'tokenizer.json', 'tokenizer_config.json'],
50
57
  };
51
58
  // The model card: prefill at most this many ids per decoder run
@@ -408,12 +415,127 @@ export async function fetchCached(url, { cache = null, force = false, onProgress
408
415
  return out;
409
416
  }
410
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
+
411
532
  // ── The engine ────────────────────────────────────────────────────────────────────────────────────────────────────
412
533
  export class Lfm3Omni {
413
534
  // opts: baseUrl (the repo layout) or files { 'onnx/decoder_q4f16.onnx': URL | Blob | ArrayBuffer | Uint8Array, … };
414
535
  // ort (onnxruntime-web/webgpu module), transformers (for PreTrainedTokenizer) or tokenizer; device 'webgpu';
415
536
  // 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)
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)
417
539
  static async load(opts = {}) {
418
540
  const { baseUrl = LFM3_OMNI_2B.baseUrl, files = {}, device = 'webgpu', onProgress = () => {}, cacheName = CACHE_NAME, force = false } = opts;
419
541
  const src = (name) => files[name] ?? new URL(name, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href;
@@ -442,7 +564,7 @@ export class Lfm3Omni {
442
564
  // the graph and its external data, both read here (byte progress); the session is built from the bytes, which
443
565
  // are dropped once onnxruntime holds the weights
444
566
  const graph = async (role) => {
445
- const file = LFM3_OMNI_2B.graphs[role];
567
+ const file = LFM3_OMNI_2B.graphs[role] ?? LFM3_OMNI_2B.speechGraphs[role];
446
568
  const model = await bytes(file), data = await bytes(file + '_data');
447
569
  onProgress({ status: 'session', file });
448
570
  return { model, data, dataPath: file.split('/').pop() + '_data' };
@@ -451,19 +573,47 @@ export class Lfm3Omni {
451
573
  const vision = await createSession(ort, await graph('vision'), sessionOpts);
452
574
  const audio = await createSession(ort, await graph('audio'), sessionOpts);
453
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();
454
580
  onProgress({ status: 'ready' });
455
- return new Lfm3Omni({ ort, tokenizer, config, gen, processor, vision, audio, decoder });
581
+ return m;
456
582
  }
457
583
 
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'])];
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'])];
461
611
  }
462
612
  // Stored copies of this model's files under baseUrl: [{ file, url, bytes }] (bytes from the stored length)
463
613
  static async cached({ baseUrl = LFM3_OMNI_2B.baseUrl, cacheName = CACHE_NAME } = {}) {
464
614
  if (!globalThis.caches) return [];
465
615
  const cache = await caches.open(cacheName), out = [];
466
- for (const file of Lfm3Omni.files()) {
616
+ for (const file of Lfm3Omni.files({ speech: true })) {
467
617
  const url = new URL(file, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/').href, r = await cache.match(url);
468
618
  if (r) out.push({ file, url, bytes: Number(r.headers.get('content-length')) || null });
469
619
  }
@@ -494,9 +644,10 @@ export class Lfm3Omni {
494
644
  mixed_start: t('mixed_start_token_id', 125015), eos: config.text_config.eos_token_id,
495
645
  think_end: tokenizer.convert_tokens_to_ids?.('</think>') ?? null,
496
646
  };
497
- // the speech-output layer reads these (lfm3_audio.config): not used in text mode
647
+ // speech out (lfm3_audio.config), and the codec's STFT (audio_tokenizer/config.json's values for this checkpoint)
498
648
  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) };
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 };
500
651
  }
501
652
 
502
653
  // The chat template's text and ids (placeholders expanded) of messages; media come back for the encoders.
@@ -561,7 +712,10 @@ export class Lfm3Omni {
561
712
  // (AbortSignal) is read before every decode step; an abort resolves, it does not throw.
562
713
  async generate(messages, opts = {}) {
563
714
  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)');
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
+ }
565
719
  const sampling = { do_sample: opts.do_sample ?? false, temperature: opts.temperature ?? 1, top_k: opts.top_k ?? 0,
566
720
  top_p: opts.top_p ?? 1, min_p: opts.min_p ?? 0, repetition_penalty: opts.repetition_penalty ?? 1 };
567
721
  const rand = seed === undefined ? Math.random : mulberry32(seed);
@@ -595,6 +749,74 @@ export class Lfm3Omni {
595
749
  return { text, stopReason, tokens, ...this.decodeText(tokens) };
596
750
  }
597
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
+
598
820
  // Greedy ids after prompt ids (the gate's comparison): no template, no stop on EOS unless asked.
599
821
  async greedyIds(ids, side = {}, n = 32, { stopAtEos = true } = {}) {
600
822
  const T = this.tokens, suppress = [T.audio, T.audio_out, T.image, T.video, T.mixed_start, T.audio_start], out = [];
@@ -618,7 +840,11 @@ export class Lfm3Omni {
618
840
  return { content: dec(tokens.slice(k + 1)).trimStart(), reasoning: dec(tokens.slice(0, k)).replace(/^\s*<think>/, '').trim() };
619
841
  }
620
842
 
621
- async dispose() { this.ready = false; await this.decoder.dispose(); await this.vision.release(); await this.audio.release(); }
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
+ }
622
848
  }
623
849
 
624
850
  function concat(parts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diskformer",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
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",