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 +13 -0
- package/engines/lfm3_omni.js +240 -14
- package/package.json +1 -1
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.
|
package/engines/lfm3_omni.js
CHANGED
|
@@ -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 (
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
581
|
+
return m;
|
|
456
582
|
}
|
|
457
583
|
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
//
|
|
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'))
|
|
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() {
|
|
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.
|
|
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",
|