@missionsquad/browserai 0.1.0 → 0.2.0
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/README.md +5 -0
- package/dist/client.d.ts +11 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +33 -12
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/hardware.d.ts +10 -0
- package/dist/hardware.d.ts.map +1 -1
- package/dist/hardware.js +15 -0
- package/dist/hardware.js.map +1 -1
- package/dist/history.d.ts +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +1 -1
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/inference.d.ts.map +1 -1
- package/dist/inference.js +8 -6
- package/dist/inference.js.map +1 -1
- package/dist/metrics.js +2 -2
- package/dist/metrics.js.map +1 -1
- package/dist/models.d.ts +12 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +142 -0
- package/dist/models.js.map +1 -1
- package/dist/multimodal.d.ts +21 -1
- package/dist/multimodal.d.ts.map +1 -1
- package/dist/multimodal.js +69 -11
- package/dist/multimodal.js.map +1 -1
- package/dist/tdt/frontend.d.ts +7 -0
- package/dist/tdt/frontend.d.ts.map +1 -0
- package/dist/tdt/frontend.js +73 -0
- package/dist/tdt/frontend.js.map +1 -0
- package/dist/tdt/index.d.ts +37 -0
- package/dist/tdt/index.d.ts.map +1 -0
- package/dist/tdt/index.js +318 -0
- package/dist/tdt/index.js.map +1 -0
- package/dist/tdt/nemo-frontend.d.ts +6 -0
- package/dist/tdt/nemo-frontend.d.ts.map +1 -0
- package/dist/tdt/nemo-frontend.js +53 -0
- package/dist/tdt/nemo-frontend.js.map +1 -0
- package/package.json +6 -3
- package/src/client.ts +35 -10
- package/src/config.ts +7 -0
- package/src/hardware.ts +15 -0
- package/src/history.ts +2 -2
- package/src/index.ts +3 -1
- package/src/inference.ts +12 -6
- package/src/metrics.ts +2 -2
- package/src/models.ts +152 -1
- package/src/multimodal.ts +64 -10
- package/src/tdt/frontend.ts +61 -0
- package/src/tdt/index.ts +230 -0
- package/src/tdt/nemo-frontend.ts +43 -0
- package/src/tdt/runtime-types.d.ts +5 -0
- package/worker-template/cloudflare-worker.ts +1 -1
package/src/multimodal.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TdtModel } from "./tdt/index.js";
|
|
1
2
|
import { type ModelPreset, type MultimodalRuntime, SUPERTONIC_2_MODEL_ID } from "./models.js";
|
|
2
3
|
import { AUDIO_SR } from "./audio/pcm.js";
|
|
3
4
|
|
|
@@ -73,6 +74,7 @@ export type ProgressCallback = (info: { status?: string; progress?: number; file
|
|
|
73
74
|
export type GenStats = { completionTokens: number; firstTokenMs?: number; startedMs: number; endedMs: number };
|
|
74
75
|
/** VLM / Gemma generation result: text plus telemetry. */
|
|
75
76
|
export type VlmResult = { text: string } & GenStats;
|
|
77
|
+
export type ImageClassificationResult = { scores: Array<{ label: string; score: number }>; startedMs: number; endedMs: number };
|
|
76
78
|
export type SttResult = { text: string; segments: Array<{ start: number; end: number; text: string }>; gen?: GenStats };
|
|
77
79
|
|
|
78
80
|
/** Count tokens from a TextStreamer token_callback payload (array / typed array / tensor). */
|
|
@@ -93,10 +95,11 @@ function makeCountingStreamer(
|
|
|
93
95
|
tokenizer: unknown,
|
|
94
96
|
onDelta: ((text: string) => void) | undefined,
|
|
95
97
|
sink: { text: string; completionTokens: number; firstTokenMs?: number },
|
|
98
|
+
preserveSpecialTokens = false,
|
|
96
99
|
): unknown {
|
|
97
100
|
return new Streamer(tokenizer, {
|
|
98
101
|
skip_prompt: true,
|
|
99
|
-
skip_special_tokens:
|
|
102
|
+
skip_special_tokens: !preserveSpecialTokens,
|
|
100
103
|
callback_function: (chunk: unknown) => {
|
|
101
104
|
if (typeof chunk === "string") {
|
|
102
105
|
if (sink.firstTokenMs === undefined) sink.firstTokenMs = performance.now();
|
|
@@ -112,6 +115,8 @@ function makeCountingStreamer(
|
|
|
112
115
|
|
|
113
116
|
/** A loaded multimodal runtime, discriminated by kind. */
|
|
114
117
|
export type MultimodalHandle =
|
|
118
|
+
| { kind: "tdt"; model: TdtModel }
|
|
119
|
+
| { kind: "image-classification"; classifier: ((src: string, opts: AnyRecord) => Promise<unknown>) & { dispose?: () => Promise<void> } }
|
|
115
120
|
| { kind: "stt"; transcriber: (input: unknown, opts?: AnyRecord) => Promise<any> }
|
|
116
121
|
| { kind: "tts-kokoro"; tts: KokoroInstance; TextSplitterStream: new () => TtsTextSplitter }
|
|
117
122
|
| { kind: "tts-pipeline"; synth: (text: string, opts: AnyRecord) => Promise<RawAudio>; modelId: string }
|
|
@@ -126,6 +131,7 @@ export type MultimodalHandle =
|
|
|
126
131
|
/** SmolVLM passes the image as [image] + a trailing {}; others pass a bare image. */
|
|
127
132
|
imageArray: boolean;
|
|
128
133
|
maxNewTokens: number;
|
|
134
|
+
visionOptions?: ModelPreset["visionOptions"];
|
|
129
135
|
}
|
|
130
136
|
| {
|
|
131
137
|
kind: "gemma";
|
|
@@ -169,7 +175,10 @@ export type LoadMultimodalOptions = {
|
|
|
169
175
|
};
|
|
170
176
|
|
|
171
177
|
function vlmDtype(preset: ModelPreset, runtime: MultimodalRuntime): unknown {
|
|
172
|
-
if (runtime === "vlm-vision2seq")
|
|
178
|
+
if (runtime === "vlm-vision2seq") {
|
|
179
|
+
if (preset.visionOptions?.outputFormat === "doctags") return preset.transformersDtype ?? "fp32";
|
|
180
|
+
return { embed_tokens: "fp16", vision_encoder: "q4", decoder_model_merged: "q4" };
|
|
181
|
+
}
|
|
173
182
|
// imagetext family: GLM-OCR ships a single q4f16 dtype; Qwen3-VL uses a per-module mixed map.
|
|
174
183
|
if (preset.transformersDtype) return preset.transformersDtype;
|
|
175
184
|
return { embed_tokens: "fp16", vision_encoder: "fp16", decoder_model_merged: "q4f16" };
|
|
@@ -204,6 +213,14 @@ export async function loadMultimodal(
|
|
|
204
213
|
if (!runtime) throw new Error(`Model ${preset.shortName} has no multimodal runtime.`);
|
|
205
214
|
|
|
206
215
|
switch (runtime) {
|
|
216
|
+
case "stt-tdt": {
|
|
217
|
+
const { loadTdtModel } = await import("./tdt/index.js");
|
|
218
|
+
return { kind: "tdt", model: await loadTdtModel(preset.id, { device, onProgress: progress_callback }) };
|
|
219
|
+
}
|
|
220
|
+
case "image-classification": {
|
|
221
|
+
const classifier = await transformers.pipeline("image-classification", preset.id, { device, dtype: preset.transformersDtype ?? "q4f16", progress_callback });
|
|
222
|
+
return { kind: "image-classification", classifier };
|
|
223
|
+
}
|
|
207
224
|
case "stt": {
|
|
208
225
|
// Whisper/Moonshine load an fp32 encoder + a quantized decoder (encoder quantization hurts
|
|
209
226
|
// accuracy). Some ASR models document a single flat dtype instead — e.g. Cohere's cohere_asr,
|
|
@@ -214,7 +231,16 @@ export async function loadMultimodal(
|
|
|
214
231
|
(device === "webgpu"
|
|
215
232
|
? { encoder_model: "fp32", decoder_model_merged: "q4" }
|
|
216
233
|
: { encoder_model: "fp32", decoder_model_merged: "q8" });
|
|
217
|
-
const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, {
|
|
234
|
+
const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, {
|
|
235
|
+
device,
|
|
236
|
+
dtype,
|
|
237
|
+
progress_callback,
|
|
238
|
+
// onnxruntime-web 1.26-dev's extended graph optimizations fail at session creation for the
|
|
239
|
+
// quantized ASR decoders on the wasm EP (qdq_actions.cc: "Missing required scale …
|
|
240
|
+
// TransposeDQWeightsForMatMulNBits"). Capping at "basic" skips the broken QDQ rewrites —
|
|
241
|
+
// verified to load and transcribe. The WebGPU path keeps the default level.
|
|
242
|
+
...(device === "wasm" ? { session_options: { graphOptimizationLevel: "basic" as const } } : {}),
|
|
243
|
+
});
|
|
218
244
|
return { kind: "stt", transcriber };
|
|
219
245
|
}
|
|
220
246
|
case "tts-pipeline": {
|
|
@@ -235,7 +261,14 @@ export async function loadMultimodal(
|
|
|
235
261
|
case "vlm-imagetext": {
|
|
236
262
|
const processor = await transformers.AutoProcessor.from_pretrained(preset.id, { progress_callback });
|
|
237
263
|
const ModelClass = runtime === "vlm-vision2seq" ? transformers.AutoModelForVision2Seq : transformers.AutoModelForImageTextToText;
|
|
238
|
-
|
|
264
|
+
let model;
|
|
265
|
+
try {
|
|
266
|
+
model = await ModelClass.from_pretrained(preset.id, { device: "webgpu", dtype: vlmDtype(preset, runtime), progress_callback });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// Release any processor-owned resources if the subsequent model load fails.
|
|
269
|
+
try { await processor.dispose?.(); } catch { /* preserve the loading error */ }
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
239
272
|
return {
|
|
240
273
|
kind: "vlm",
|
|
241
274
|
processor,
|
|
@@ -245,7 +278,8 @@ export async function loadMultimodal(
|
|
|
245
278
|
maxPixels: runtime === "vlm-imagetext" ? MAX_PIXELS : 0,
|
|
246
279
|
imageArray: runtime === "vlm-vision2seq",
|
|
247
280
|
// Generous caps so descriptions/OCR are not truncated mid-output (any OCR model → 2048).
|
|
248
|
-
maxNewTokens: preset.id.toLowerCase().includes("ocr") ? 2048 : 1024,
|
|
281
|
+
maxNewTokens: preset.visionOptions?.maxNewTokens ?? (preset.id.toLowerCase().includes("ocr") ? 2048 : 1024),
|
|
282
|
+
visionOptions: preset.visionOptions,
|
|
249
283
|
};
|
|
250
284
|
}
|
|
251
285
|
case "gemma-mm": {
|
|
@@ -274,6 +308,10 @@ async function loadKokoroModule(options?: LoadMultimodalOptions["kokoro"]): Prom
|
|
|
274
308
|
|
|
275
309
|
export async function disposeMultimodal(handle: MultimodalHandle | null): Promise<void> {
|
|
276
310
|
if (!handle) return;
|
|
311
|
+
if (handle.kind === "image-classification") {
|
|
312
|
+
try { await handle.classifier.dispose?.(); } catch { /* best-effort cleanup */ }
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
277
315
|
const model = (handle as { model?: { dispose?: () => unknown } }).model;
|
|
278
316
|
try {
|
|
279
317
|
if (typeof model?.dispose === "function") await model.dispose();
|
|
@@ -320,10 +358,12 @@ export function planGemmaAudioWindows(totalSamples: number): AudioWindow[] {
|
|
|
320
358
|
* the generative STT recipes (Gemma, Granite Speech, Voxtral) require decoded PCM.
|
|
321
359
|
* @throws {Error} when a URL is passed to a model that cannot fetch audio itself.
|
|
322
360
|
*/
|
|
323
|
-
export async function transcribe(handle: MultimodalHandle, audio: Float32Array | string, onDelta?: (text: string) => void): Promise<SttResult> {
|
|
361
|
+
export async function transcribe(handle: MultimodalHandle, audio: Float32Array | string, onDelta?: (text: string) => void, signal?: AbortSignal): Promise<SttResult> {
|
|
324
362
|
if (typeof audio === "string" && handle.kind !== "stt") {
|
|
325
363
|
throw new Error("URL audio input is only supported by ASR-pipeline models (Whisper/Moonshine). Decode to 16 kHz mono PCM first (decodeAudioTo16kMono).");
|
|
326
364
|
}
|
|
365
|
+
signal?.throwIfAborted();
|
|
366
|
+
if (handle.kind === "tdt") return handle.model.transcribe(audio as Float32Array, onDelta, signal);
|
|
327
367
|
if (handle.kind === "gemma") {
|
|
328
368
|
// Callers supply decoded 16 kHz samples; chunk anything past Gemma's ~30s window.
|
|
329
369
|
return transcribeGemmaChunked(handle, audio as Float32Array, onDelta);
|
|
@@ -566,22 +606,36 @@ export function synthesizeStream(
|
|
|
566
606
|
return { splitter, chunks: chunks() };
|
|
567
607
|
}
|
|
568
608
|
|
|
609
|
+
/** Classify one image, returning all labels and inference timing. */
|
|
610
|
+
export async function classifyImage(handle: MultimodalHandle, imageSrc: string): Promise<ImageClassificationResult> {
|
|
611
|
+
if (handle.kind !== "image-classification") throw new Error("Loaded model cannot classify images; select an image-classification model.");
|
|
612
|
+
if (!imageSrc.trim()) throw new Error("An image source is required.");
|
|
613
|
+
const startedMs = performance.now();
|
|
614
|
+
const output = await handle.classifier(imageSrc, { top_k: null });
|
|
615
|
+
if (!Array.isArray(output) || !output.every((item: unknown) => typeof item === "object" && item !== null && "label" in item && typeof item.label === "string" && "score" in item && typeof item.score === "number" && Number.isFinite(item.score))) {
|
|
616
|
+
throw new Error("Image classifier returned invalid label scores.");
|
|
617
|
+
}
|
|
618
|
+
return { scores: output.map((item: { label: string; score: number }) => ({ label: item.label, score: item.score })), startedMs, endedMs: performance.now() };
|
|
619
|
+
}
|
|
620
|
+
|
|
569
621
|
/** Answer a question about an image (VLM). Streams partial text via onDelta; returns text + telemetry. */
|
|
570
622
|
export async function describeImage(handle: MultimodalHandle, imageSrc: string, prompt: string, onDelta?: (text: string) => void): Promise<VlmResult> {
|
|
623
|
+
if (handle.kind === "image-classification") throw new Error("Loaded model classifies images; use classifyImage() to obtain label scores.");
|
|
571
624
|
if (handle.kind === "gemma") return runGemma(handle, { imageSrc, prompt }, onDelta);
|
|
572
625
|
if (handle.kind !== "vlm") throw new Error("Loaded model cannot analyze images.");
|
|
573
626
|
|
|
574
|
-
const messages = [{ role: "user", content: [{ type: "image" }, { type: "text", text: prompt }] }];
|
|
627
|
+
const messages = [{ role: "user", content: [{ type: "image" }, { type: "text", text: prompt.trim() || handle.visionOptions?.defaultPrompt || prompt }] }];
|
|
575
628
|
const text = handle.processor.apply_chat_template(messages, { add_generation_prompt: true });
|
|
576
629
|
const src = handle.maxPixels ? await capSrc(imageSrc, handle.maxPixels) : imageSrc;
|
|
577
630
|
const image = await handle.loadImage(src);
|
|
578
|
-
const inputs = handle.imageArray ? await handle.processor(text, [image], {}) : await handle.processor(text, image);
|
|
631
|
+
const inputs = handle.imageArray ? await handle.processor(text, [image], handle.visionOptions?.doImageSplitting === undefined ? {} : { do_image_splitting: handle.visionOptions.doImageSplitting }) : await handle.processor(text, image);
|
|
579
632
|
|
|
580
633
|
const sink = { text: "", completionTokens: 0, firstTokenMs: undefined as number | undefined };
|
|
581
634
|
const startedMs = performance.now();
|
|
582
|
-
const
|
|
635
|
+
const cleanOutput = (value: string): string => handle.visionOptions?.outputFormat === "doctags" ? value.replace(/<\|end_of_text\|>\s*$/, "").trimEnd() : value;
|
|
636
|
+
const streamer = makeCountingStreamer(handle.Streamer, handle.processor.tokenizer, onDelta ? (value) => onDelta(cleanOutput(value)) : undefined, sink, handle.visionOptions?.preserveSpecialTokens);
|
|
583
637
|
await handle.model.generate({ ...inputs, max_new_tokens: handle.maxNewTokens, do_sample: false, streamer });
|
|
584
|
-
return { text: sink.text.trim(), completionTokens: sink.completionTokens, firstTokenMs: sink.firstTokenMs, startedMs, endedMs: performance.now() };
|
|
638
|
+
return { text: cleanOutput(sink.text).trim(), completionTokens: sink.completionTokens, firstTokenMs: sink.firstTokenMs, startedMs, endedMs: performance.now() };
|
|
585
639
|
}
|
|
586
640
|
|
|
587
641
|
/** Gemma-4 text + image + audio. image/audio placeholders go before the text; audio is a 16 kHz Float32Array. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Phonon reference front end: NeMo log-mel, without dither. */
|
|
2
|
+
export function phononFeatures(audio: Float32Array): { data: Float32Array; frames: number } {
|
|
3
|
+
const frames = Math.floor(audio.length / 160);
|
|
4
|
+
if (frames < 2) throw new Error('TDT audio must contain at least 320 samples (20 ms).');
|
|
5
|
+
const emphasized = new Float32Array(audio.length);
|
|
6
|
+
emphasized[0] = audio[0];
|
|
7
|
+
for (let i = 1; i < audio.length; i++) emphasized[i] = audio[i] - .97 * audio[i - 1];
|
|
8
|
+
const hzToMel = (hz: number): number => hz < 1000 ? hz / (200 / 3) : 15 + Math.log(hz / 1000) / (Math.log(6.4) / 27);
|
|
9
|
+
const melToHz = (mel: number): number => mel < 15 ? mel * (200 / 3) : 1000 * Math.exp((mel - 15) * Math.log(6.4) / 27);
|
|
10
|
+
const edges = Array.from({ length: 130 }, (_, i) => melToHz(hzToMel(8000) * i / 129));
|
|
11
|
+
const filters = Array.from({ length: 128 }, (_, m) => Float64Array.from({ length: 257 }, (_, k) => {
|
|
12
|
+
const hz = k * 16000 / 512;
|
|
13
|
+
return Math.max(0, Math.min((hz - edges[m]) / (edges[m + 1] - edges[m]), (edges[m + 2] - hz) / (edges[m + 2] - edges[m + 1]))) * 2 / (edges[m + 2] - edges[m]);
|
|
14
|
+
}));
|
|
15
|
+
const window = Float64Array.from({ length: 400 }, (_, i) => .5 - .5 * Math.cos(2 * Math.PI * i / 399));
|
|
16
|
+
const data = new Float32Array(frames * 128);
|
|
17
|
+
const real = new Float64Array(512), imag = new Float64Array(512);
|
|
18
|
+
for (let f = 0; f < frames; f++) {
|
|
19
|
+
real.fill(0); imag.fill(0);
|
|
20
|
+
for (let i = 0; i < 400; i++) {
|
|
21
|
+
const sample = f * 160 + i - 200;
|
|
22
|
+
real[i + 56] = (sample >= 0 && sample < audio.length ? emphasized[sample] : 0) * window[i];
|
|
23
|
+
}
|
|
24
|
+
fft(real, imag);
|
|
25
|
+
for (let m = 0; m < 128; m++) {
|
|
26
|
+
let power = 0;
|
|
27
|
+
for (let k = 0; k <= 256; k++) power += (real[k] ** 2 + imag[k] ** 2) * filters[m][k];
|
|
28
|
+
data[f * 128 + m] = Math.log(power + 2 ** -24);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (let m = 0; m < 128; m++) {
|
|
32
|
+
let mean = 0;
|
|
33
|
+
for (let f = 0; f < frames; f++) mean += data[f * 128 + m];
|
|
34
|
+
mean /= frames;
|
|
35
|
+
let variance = 0;
|
|
36
|
+
for (let f = 0; f < frames; f++) variance += (data[f * 128 + m] - mean) ** 2;
|
|
37
|
+
const std = Math.sqrt(variance / (frames - 1)) + 1e-5;
|
|
38
|
+
for (let f = 0; f < frames; f++) data[f * 128 + m] = (data[f * 128 + m] - mean) / std;
|
|
39
|
+
}
|
|
40
|
+
return { data, frames };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fft(real: Float64Array, imag: Float64Array): void {
|
|
44
|
+
for (let i = 1, j = 0; i < 512; i++) {
|
|
45
|
+
let bit = 256;
|
|
46
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
47
|
+
j ^= bit;
|
|
48
|
+
if (i < j) { [real[i], real[j]] = [real[j], real[i]]; }
|
|
49
|
+
}
|
|
50
|
+
for (let size = 2; size <= 512; size *= 2) {
|
|
51
|
+
for (let start = 0; start < 512; start += size) {
|
|
52
|
+
for (let j = 0; j < size / 2; j++) {
|
|
53
|
+
const angle = -2 * Math.PI * j / size, c = Math.cos(angle), s = Math.sin(angle);
|
|
54
|
+
const a = start + j, b = a + size / 2;
|
|
55
|
+
const r = c * real[b] - s * imag[b], im = s * real[b] + c * imag[b];
|
|
56
|
+
real[b] = real[a] - r; imag[b] = imag[a] - im;
|
|
57
|
+
real[a] += r; imag[a] += im;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/tdt/index.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/// <reference path="./runtime-types.d.ts" />
|
|
2
|
+
import * as ort from 'onnxruntime-tdt/webgpu';
|
|
3
|
+
import { phononFeatures } from './frontend.js';
|
|
4
|
+
import { nemoFeatures } from './nemo-frontend.js';
|
|
5
|
+
|
|
6
|
+
export interface TdtSegment { start: number; end: number; text: string }
|
|
7
|
+
export interface TdtModel {
|
|
8
|
+
transcribe(audio: Float32Array, onDelta?: (text: string) => void, signal?: AbortSignal): Promise<{ text: string; segments: TdtSegment[] }>;
|
|
9
|
+
dispose(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface TdtLoadOptions {
|
|
12
|
+
device: 'webgpu' | 'wasm'; baseUrl?: string; signal?: AbortSignal;
|
|
13
|
+
wasmPaths?: string | { mjs: string; wasm: string };
|
|
14
|
+
onProgress?: (info: { status?: string; progress?: number; file?: string }) => void;
|
|
15
|
+
}
|
|
16
|
+
export function checkAbort(signal?: AbortSignal): void { signal?.throwIfAborted(); }
|
|
17
|
+
/** Let queued input/cancellation events run even when WASM resolves only microtasks. */
|
|
18
|
+
async function yieldToBrowser(signal?: AbortSignal): Promise<void> {
|
|
19
|
+
checkAbort(signal);
|
|
20
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
21
|
+
checkAbort(signal);
|
|
22
|
+
}
|
|
23
|
+
export function parseVocabulary(text: string): string[] {
|
|
24
|
+
const vocab: string[] = [];
|
|
25
|
+
for (const line of text.split(/\r?\n/)) {
|
|
26
|
+
if (!line) continue;
|
|
27
|
+
const match = /^(.*) (\d+)$/.exec(line);
|
|
28
|
+
if (!match) throw new Error('Invalid TDT vocabulary line.');
|
|
29
|
+
const id = Number(match[2]);
|
|
30
|
+
if (vocab[id] !== undefined) throw new Error('Duplicate TDT vocabulary ID.');
|
|
31
|
+
vocab[id] = match[1];
|
|
32
|
+
}
|
|
33
|
+
if (!vocab.length || Array.from({ length: vocab.length }, (_, i) => vocab[i]).some(x => x === undefined)) throw new Error('Incomplete TDT vocabulary.');
|
|
34
|
+
return vocab;
|
|
35
|
+
}
|
|
36
|
+
export function decodePieces(ids: number[], vocab: string[]): string {
|
|
37
|
+
return ids.map(id => vocab[id] ?? '').filter(piece => piece !== '<unk>' && piece !== '<pad>' && piece !== '<blk>' && !/^<\|.*\|>$/.test(piece)).join('').replace(/▁/g, ' ').trim();
|
|
38
|
+
}
|
|
39
|
+
/** Split near the quietest 100 ms interval in the final five seconds, bounded at 30 seconds. */
|
|
40
|
+
export function splitAudio(audio: Float32Array): Array<{ start: number; end: number }> {
|
|
41
|
+
const chunks: Array<{ start: number; end: number }> = [];
|
|
42
|
+
let start = 0;
|
|
43
|
+
while (audio.length - start > 480000) {
|
|
44
|
+
let end = start + 480000, lowest = Infinity;
|
|
45
|
+
for (let p = start + 400000; p <= start + 478400; p += 1600) {
|
|
46
|
+
let energy = 0;
|
|
47
|
+
for (let i = p; i < p + 1600; i++) energy += audio[i] ** 2;
|
|
48
|
+
if (energy < lowest) { lowest = energy; end = p + 800; }
|
|
49
|
+
}
|
|
50
|
+
chunks.push({ start, end }); start = end;
|
|
51
|
+
}
|
|
52
|
+
if (start < audio.length) chunks.push({ start, end: audio.length });
|
|
53
|
+
return chunks;
|
|
54
|
+
}
|
|
55
|
+
async function artifact(url: string, options: TdtLoadOptions): Promise<Uint8Array> {
|
|
56
|
+
checkAbort(options.signal);
|
|
57
|
+
const cacheWarning = (): void => options.onProgress?.({ status: 'cache-warning', file: url });
|
|
58
|
+
let cache: Cache | undefined;
|
|
59
|
+
let response: Response | undefined;
|
|
60
|
+
try { cache = typeof caches !== 'undefined' ? await caches.open('transformers-cache') : undefined; }
|
|
61
|
+
catch { cacheWarning(); }
|
|
62
|
+
try { response = await cache?.match(url); }
|
|
63
|
+
catch { cacheWarning(); }
|
|
64
|
+
const cachePut = async (value: Response): Promise<void> => {
|
|
65
|
+
if (!cache) return;
|
|
66
|
+
try { await cache.put(url, value); }
|
|
67
|
+
catch { cacheWarning(); }
|
|
68
|
+
};
|
|
69
|
+
if (!response) {
|
|
70
|
+
response = await fetch(url, { signal: options.signal });
|
|
71
|
+
if (!response.ok) throw new Error(`TDT download failed (${response.status}): ${url}`);
|
|
72
|
+
// Consume incrementally for meaningful download progress, then cache only a complete artifact.
|
|
73
|
+
const total = Number(response.headers.get('content-length'));
|
|
74
|
+
const reader = response.body?.getReader();
|
|
75
|
+
if (reader) {
|
|
76
|
+
const chunks: Uint8Array[] = []; let received = 0;
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
checkAbort(options.signal);
|
|
80
|
+
const next = await reader.read(); if (next.done) break;
|
|
81
|
+
chunks.push(next.value); received += next.value.length;
|
|
82
|
+
options.onProgress?.({ status: 'progress', file: url, ...(total > 0 ? { progress: Math.min(100, received / total * 100) } : {}) });
|
|
83
|
+
}
|
|
84
|
+
} finally { reader.releaseLock(); }
|
|
85
|
+
const bytes = new Uint8Array(received); let offset = 0;
|
|
86
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
87
|
+
checkAbort(options.signal);
|
|
88
|
+
await cachePut(new Response(bytes, { headers: response.headers }));
|
|
89
|
+
options.onProgress?.({ status: 'done', file: url, progress: 100 });
|
|
90
|
+
return bytes;
|
|
91
|
+
}
|
|
92
|
+
await cachePut(response.clone());
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) throw new Error(`Invalid cached TDT artifact (${response.status}): ${url}`);
|
|
95
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
96
|
+
checkAbort(options.signal);
|
|
97
|
+
options.onProgress?.({ status: 'done', file: url, progress: 100 });
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
function floats(tensor: ort.Tensor | undefined, name: string): Float32Array {
|
|
101
|
+
if (!tensor || tensor.type !== 'float32' || !(tensor.data instanceof Float32Array)) throw new Error(`Invalid TDT ${name} tensor.`);
|
|
102
|
+
return tensor.data;
|
|
103
|
+
}
|
|
104
|
+
function releaseTensors(values: Record<string, ort.Tensor>): void { for (const tensor of new Set(Object.values(values))) tensor.dispose(); }
|
|
105
|
+
function argmax(values: Float32Array, start: number, end: number): number {
|
|
106
|
+
let best = start; for (let i = start + 1; i < end; i++) if (values[i] > values[best]) best = i;
|
|
107
|
+
return best - start;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Load the two explicitly supported ONNX TDT exports. Sessions are released on any partial load failure. */
|
|
111
|
+
export async function loadTdtModel(modelId: string, options: TdtLoadOptions): Promise<TdtModel> {
|
|
112
|
+
const phonon = modelId === 'enriqueag/phonon-2.5-onnx';
|
|
113
|
+
if (!phonon && modelId !== 'istupakov/parakeet-tdt-0.6b-v3-onnx') throw new Error(`Unsupported TDT model: ${modelId}`);
|
|
114
|
+
if (phonon && options.device !== 'webgpu') throw new Error('Phonon exact2x2 requires WebGPU.');
|
|
115
|
+
const base = options.baseUrl ?? `https://huggingface.co/${modelId}/resolve/main/`;
|
|
116
|
+
const url = (file: string): string => new URL(file, base.endsWith('/') ? base : `${base}/`).href;
|
|
117
|
+
ort.env.wasm.numThreads = 1;
|
|
118
|
+
ort.env.wasm.wasmPaths = options.wasmPaths ?? {
|
|
119
|
+
mjs: 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.1/dist/ort-wasm-simd-threaded.asyncify.mjs',
|
|
120
|
+
wasm: 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.1/dist/ort-wasm-simd-threaded.asyncify.wasm',
|
|
121
|
+
};
|
|
122
|
+
const sessions: ort.InferenceSession[] = [];
|
|
123
|
+
const create = async (file: string, device: 'webgpu' | 'wasm', decoder = false): Promise<ort.InferenceSession> => {
|
|
124
|
+
const bytes = await artifact(url(file), options); checkAbort(options.signal);
|
|
125
|
+
const session = await ort.InferenceSession.create(bytes, { executionProviders: [device], ...(decoder && phonon ? { extra: { session: { disable_quant_qdq: '1' } } } : {}) });
|
|
126
|
+
sessions.push(session); checkAbort(options.signal); return session;
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
const vocab = parseVocabulary(new TextDecoder().decode(await artifact(url('vocab.txt'), options)));
|
|
130
|
+
const blank = phonon ? 8192 : vocab.indexOf('<blk>');
|
|
131
|
+
if (blank < 0 || (phonon && vocab.length !== 8192 && vocab.length !== 8193)) throw new Error('Invalid TDT blank/vocabulary configuration.');
|
|
132
|
+
const encoder = await create(phonon ? 'encoder.exact2x2.onnx' : 'encoder-model.int8.onnx', phonon ? 'webgpu' : 'wasm');
|
|
133
|
+
const decoder = await create(phonon ? 'decoder_joint.int8.onnx' : 'decoder_joint-model.int8.onnx', 'wasm', true);
|
|
134
|
+
let disposed = false, busy = false;
|
|
135
|
+
const stateShape = [2, 1, 640];
|
|
136
|
+
return {
|
|
137
|
+
async dispose() {
|
|
138
|
+
if (busy) throw new Error('Cannot dispose TDT model during transcription.');
|
|
139
|
+
if (!disposed) { disposed = true; await Promise.all(sessions.map(s => s.release())); }
|
|
140
|
+
},
|
|
141
|
+
async transcribe(audio, onDelta, signal) {
|
|
142
|
+
if (disposed) throw new Error('TDT model is disposed.');
|
|
143
|
+
if (busy) throw new Error('TDT model is already transcribing.');
|
|
144
|
+
if (!(audio instanceof Float32Array) || audio.some(x => !Number.isFinite(x))) throw new Error('TDT requires finite mono 16 kHz Float32 audio.');
|
|
145
|
+
checkAbort(signal); busy = true;
|
|
146
|
+
const segments: TdtSegment[] = [];
|
|
147
|
+
let runsSinceYield = 0, lastYield = performance.now();
|
|
148
|
+
try {
|
|
149
|
+
for (const chunk of splitAudio(audio)) {
|
|
150
|
+
checkAbort(signal);
|
|
151
|
+
const wave = audio.slice(chunk.start, chunk.end);
|
|
152
|
+
if (wave.length < 320) continue;
|
|
153
|
+
const resources = new Set<ort.Tensor>();
|
|
154
|
+
const own = <T extends ort.Tensor>(t: T): T => { resources.add(t); return t; };
|
|
155
|
+
const run = async (session: ort.InferenceSession, feeds: Record<string, ort.Tensor>): Promise<Record<string, ort.Tensor>> => {
|
|
156
|
+
checkAbort(signal);
|
|
157
|
+
if (++runsSinceYield >= 32 || performance.now() - lastYield >= 20) {
|
|
158
|
+
await yieldToBrowser(signal); runsSinceYield = 0; lastYield = performance.now();
|
|
159
|
+
}
|
|
160
|
+
const outputs = await session.run(feeds);
|
|
161
|
+
for (const tensor of Object.values(outputs)) own(tensor);
|
|
162
|
+
checkAbort(signal); return outputs;
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
let encoded: Record<string, ort.Tensor>;
|
|
166
|
+
if (phonon) {
|
|
167
|
+
await yieldToBrowser(signal);
|
|
168
|
+
const features = phononFeatures(wave);
|
|
169
|
+
await yieldToBrowser(signal);
|
|
170
|
+
runsSinceYield = 0; lastYield = performance.now();
|
|
171
|
+
encoded = await run(encoder, { features: own(new ort.Tensor('float32', features.data, [1, features.frames, 128])) });
|
|
172
|
+
} else {
|
|
173
|
+
// The published nemo128 graph uses float64 STFT, unsupported by ORT Web.
|
|
174
|
+
// This front end reproduces that graph's reflect padding and statistics.
|
|
175
|
+
await yieldToBrowser(signal);
|
|
176
|
+
const features = nemoFeatures(wave);
|
|
177
|
+
await yieldToBrowser(signal);
|
|
178
|
+
runsSinceYield = 0; lastYield = performance.now();
|
|
179
|
+
encoded = await run(encoder, {
|
|
180
|
+
audio_signal: own(new ort.Tensor('float32', features.data, [1, 128, features.frames])),
|
|
181
|
+
length: own(new ort.Tensor('int64', BigInt64Array.of(BigInt(features.frames)), [1])),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Large encoder runs are synchronous on WASM. Give input handlers a turn
|
|
185
|
+
// before entering the otherwise microtask-only decoder loop.
|
|
186
|
+
await yieldToBrowser(signal); runsSinceYield = 0; lastYield = performance.now();
|
|
187
|
+
const output = encoded[phonon ? 'encoded' : 'outputs'];
|
|
188
|
+
const data = floats(output, 'encoder');
|
|
189
|
+
const width = output.dims[phonon ? 2 : 1], paddedSteps = output.dims[phonon ? 1 : 2];
|
|
190
|
+
const lengths = encoded.encoded_lengths;
|
|
191
|
+
const steps = phonon ? paddedSteps : Number(lengths?.data[0]);
|
|
192
|
+
if (!Number.isInteger(steps) || steps < 0 || steps > paddedSteps) throw new Error('Invalid TDT encoder length.');
|
|
193
|
+
let h: ort.Tensor = own(new ort.Tensor('float32', new Float32Array(stateShape.reduce((a, b) => a * b, 1)), stateShape));
|
|
194
|
+
let c: ort.Tensor = own(new ort.Tensor('float32', new Float32Array(stateShape.reduce((a, b) => a * b, 1)), stateShape));
|
|
195
|
+
let token = blank, t = 0, emitted = 0;
|
|
196
|
+
const ids: number[] = [];
|
|
197
|
+
while (t < steps) {
|
|
198
|
+
const frame = new Float32Array(width);
|
|
199
|
+
for (let i = 0; i < width; i++) frame[i] = data[phonon ? t * width + i : i * paddedSteps + t];
|
|
200
|
+
const ephemeral: Record<string, ort.Tensor> = phonon ? {
|
|
201
|
+
encoded: new ort.Tensor('float32', frame, [1, width]), token: new ort.Tensor('int32', Int32Array.of(token), [1]),
|
|
202
|
+
} : {
|
|
203
|
+
encoder_outputs: new ort.Tensor('float32', frame, [1, width, 1]), targets: new ort.Tensor('int32', Int32Array.of(token), [1, 1]), target_length: new ort.Tensor('int32', Int32Array.of(1), [1]),
|
|
204
|
+
};
|
|
205
|
+
let result: Record<string, ort.Tensor>;
|
|
206
|
+
try { result = await run(decoder, { ...ephemeral, ...(phonon ? { h, c } : { input_states_1: h, input_states_2: c }) }); }
|
|
207
|
+
finally { releaseTensors(ephemeral); }
|
|
208
|
+
const logits = floats(result[phonon ? 'logits' : 'outputs'], 'decoder');
|
|
209
|
+
if (logits.length !== blank + 1 + 5) throw new Error('Invalid TDT token/duration logits.');
|
|
210
|
+
const next = argmax(logits, 0, blank + 1); let duration = argmax(logits, blank + 1, logits.length);
|
|
211
|
+
if (next !== blank) {
|
|
212
|
+
ids.push(next); token = next; emitted++;
|
|
213
|
+
resources.delete(h); h.dispose(); resources.delete(c); c.dispose();
|
|
214
|
+
h = result[phonon ? 'h_out' : 'output_states_1']; c = result[phonon ? 'c_out' : 'output_states_2'];
|
|
215
|
+
}
|
|
216
|
+
for (const tensor of Object.values(result)) if (tensor !== h && tensor !== c) { resources.delete(tensor); tensor.dispose(); }
|
|
217
|
+
if ((next === blank || emitted >= 10) && duration === 0) duration = 1;
|
|
218
|
+
if (duration > 0) emitted = 0;
|
|
219
|
+
t += duration;
|
|
220
|
+
}
|
|
221
|
+
const text = decodePieces(ids, vocab);
|
|
222
|
+
if (text) { segments.push({ start: chunk.start / 16000, end: chunk.end / 16000, text }); onDelta?.(segments.map(segment => segment.text).join(' ')); }
|
|
223
|
+
} finally { for (const tensor of resources) tensor.dispose(); }
|
|
224
|
+
}
|
|
225
|
+
return { text: segments.map(s => s.text).join(' '), segments };
|
|
226
|
+
} finally { busy = false; }
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
} catch (error) { await Promise.allSettled(sessions.map(s => s.release())); throw error; }
|
|
230
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fft } from './frontend.js';
|
|
2
|
+
|
|
3
|
+
// Exact sparse mel matrix from istupakov/parakeet-tdt-0.6b-v3-onnx/nemo128.onnx.
|
|
4
|
+
// Source graph SHA-256: a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f.
|
|
5
|
+
// Keeping these small constants avoids rounding differences in the exported filter edges.
|
|
6
|
+
const MEL: number[][] = [[1,0,0.02837754227221012],[1,1,0.014389011077582836],[2,1,0.01398853026330471],[2,2,0.028778022155165672],[3,3,0.04236607998609543],[3,4,0.0004004797956440598],[4,4,0.027977053076028824],[4,5,0.014789499342441559],[5,5,0.013588042929768562],[5,6,0.02917851135134697],[6,7,0.04196559265255928],[6,8,0.0008009598823264241],[7,8,0.027576573193073273],[7,9,0.01518999319523573],[8,9,0.013187538832426071],[8,10,0.029579007998108864],[9,11,0.041565097868442535],[9,12,0.0012014397652819753],[10,12,0.02717609517276287],[10,13,0.015590474009513855],[11,13,0.01278705894947052],[11,14,0.029979487881064415],[12,15,0.041164617985486984],[12,16,0.0016019197646528482],[13,16,0.02677561342716217],[13,17,0.015990953892469406],[14,17,0.012386579066514969],[14,18,0.03037998639047146],[15,19,0.040764085948467255],[15,20,0.002002452965825796],[16,20,0.026375113055109978],[16,21,0.016391444951295853],[17,21,0.011986115016043186],[17,22,0.030780402943491936],[18,23,0.04036368802189827],[18,24,0.0024028795305639505],[19,24,0.025974653661251068],[19,25,0.01679191365838051],[20,25,0.011585619300603867],[20,26,0.03118094801902771],[21,27,0.03996315225958824],[21,28,0.0028034152928739786],[22,28,0.02557411789894104],[22,29,0.017192449420690536],[23,29,0.011185083538293839],[23,30,0.031581442803144455],[24,31,0.03956267610192299],[24,32,0.0032038395293056965],[25,32,0.025173693895339966],[25,33,0.01759287342429161],[26,33,0.010784659534692764],[26,34,0.03198190778493881],[27,35,0.03916219249367714],[27,36,0.0036043752916157246],[28,36,0.024773158133029938],[28,37,0.01799340918660164],[29,37,0.010384122841060162],[29,38,0.03238244354724884],[30,39,0.03876161575317383],[30,40,0.004004895221441984],[31,40,0.024372702464461327],[31,41,0.018320715054869652],[32,41,0.010202586650848389],[32,42,0.03161151707172394],[33,43,0.03855384513735771],[33,44,0.001656117383390665],[34,44,0.028360538184642792],[34,45,0.010670339688658714],[35,45,0.01995559222996235],[35,46,0.01796126179397106],[36,46,0.013171199709177017],[36,47,0.023691896349191666],[37,47,0.007852383889257908],[37,48,0.0280131958425045],[38,48,0.0038550780154764652],[38,49,0.031065288931131363],[39,49,0.001046702265739441],[39,50,0.03297678381204605],[40,51,0.0325256809592247],[40,52,0.0006470278021879494],[41,52,0.03098229691386223],[41,53,0.0013818024890497327],[42,53,0.03026564046740532],[42,54,0.0013279326958581805],[43,54,0.03028314933180809],[44,54,0.000853460980579257],[43,55,0.0005752422730438411],[44,55,0.029303690418601036],[45,55,0.002906062640249729],[45,56,0.026582011952996254],[46,56,0.00546437269076705],[46,57,0.023383773863315582],[47,57,0.00845953170210123],[47,58,0.019775748252868652],[48,58,0.011828471906483173],[48,59,0.015819108113646507],[49,59,0.015513437800109386],[49,60,0.01156991720199585],[50,60,0.01946169137954712],[50,61,0.007079293020069599],[51,61,0.02362503670156002],[52,61,0.0026273371186107397],[51,62,0.002393868751823902],[52,62,0.022891240194439888],[53,62,0.007950658909976482],[53,63,0.017088213935494423],[54,63,0.013358916155993938],[54,64,0.011216753162443638],[55,64,0.01881822757422924],[56,64,0.0006491197855211794],[55,65,0.005309573374688625],[56,65,0.023045793175697327],[57,65,0.006984363775700331],[57,66,0.016297349706292152],[58,66,0.013272317126393318],[58,67,0.009608481079339981],[59,67,0.019490249454975128],[60,67,0.003768591908738017],[59,68,0.0030010610353201628],[60,68,0.018348077312111855],[61,68,0.010636676102876663],[61,69,0.011118832044303417],[62,69,0.01736198551952839],[63,69,0.00308591709472239],[62,70,0.004041498526930809],[63,70,0.017977513372898102],[64,70,0.010326532647013664],[64,71,0.010409270413219929],[65,71,0.017366580665111542],[66,71,0.00440334714949131],[65,72,0.0030487067997455597],[66,72,0.015703044831752777],[67,72,0.011844555847346783],[67,73,0.007962611503899097],[68,73,0.019040562212467194],[69,73,0.007269133348017931],[68,74,0.000472926622023806],[69,74,0.011963930912315845],[70,74,0.01476955134421587],[71,74,0.003552281530573964],[70,75,0.00418809661641717],[71,75,0.01513804029673338],[72,75,0.0113017363473773],[73,75,0.0006128569366410375],[72,76,0.007129882462322712],[73,76,0.01756405457854271],[74,76,0.00856065098196268],[74,77,0.009372946806252003],[75,77,0.01618116907775402],[76,77,0.00647500017657876],[75,78,0.0015116231516003609],[76,78,0.010986563749611378],[77,78,0.014228261075913906],[78,78,0.004979042336344719],[77,79,0.003005674807354808],[78,79,0.012034479528665543],[79,79,0.012825818732380867],[80,79,0.004012242890894413],[79,80,0.003972089849412441],[80,80,0.01257565151900053],[81,80,0.01191700343042612],[82,80,0.0035183706786483526],[81,81,0.00446630734950304],[82,81,0.012664892710745335],[83,81,0.011448914185166359],[84,81,0.0034456099383533],[83,82,0.004539744462817907],[84,82,0.012352367863059044],[85,82,0.011372808367013931],[86,82,0.003746368922293186],[85,83,0.004239588975906372],[86,83,0.011684300377964973],[87,83,0.011644029058516026],[88,83,0.004376695025712252],[87,84,0.0036092570517212152],[88,84,0.010703402571380138],[89,84,0.012221302837133408],[90,84,0.005296255461871624],[89,85,0.0026888747233897448],[90,85,0.009448954835534096],[91,85,0.013066906481981277],[92,85,0.006467771250754595],[91,86,0.0015151641564443707],[92,86,0.007957103662192822],[93,86,0.014145628549158573],[94,86,0.007857220247387886],[95,86,0.0015688107814639807],[93,87,0.0001221956918016076],[94,87,0.006260755006223917],[95,87,0.012399313971400261],[96,87,0.009433379396796227],[97,87,0.003441078355535865],[96,88,0.00439020711928606],[97,88,0.010239733383059502],[98,88,0.01116772647947073],[99,88,0.005457570310682058],[98,89,0.0023731838446110487],[99,89,0.007947309873998165],[100,89,0.013034172356128693],[101,89,0.007592816837131977],[102,89,0.0021514608524739742],[100,90,0.00023496054927818477],[101,90,0.005546679254621267],[102,90,0.010858396999537945],[103,90,0.009823615662753582],[104,90,0.004638473968952894],[103,91,0.0030602705664932728],[104,91,0.008121836930513382],[105,91,0.012128844857215881],[106,91,0.0071879224851727486],[107,91,0.002247000113129616],[105,92,0.0005085079465061426],[106,92,0.005331702530384064],[107,92,0.010154897347092628],[108,92,0.009781261906027794],[109,92,0.0050729489885270596],[110,92,0.0003646367113105953],[108,93,0.002505941316485405],[109,93,0.007102088537067175],[110,93,0.011698235757648945],[111,93,0.007915019057691097],[112,93,0.0034283846616744995],[111,94,0.004040570929646492],[112,94,0.008420334197580814],[113,94,0.010758612304925919],[114,94,0.00648314505815506],[115,94,0.0022076775785535574],[113,95,0.0009844168089330196],[114,95,0.005158022977411747],[115,95,0.009331629611551762],[116,95,0.009516296908259392],[117,95,0.005442172288894653],[118,95,0.0013680467382073402],[116,96,0.0019234465435147285],[117,96,0.005900462623685598],[118,96,0.009877478703856468],[119,96,0.008634302765130997],[120,96,0.004752081818878651],[121,96,0.0008698602323420346],[119,97,0.0025171886663883924],[120,97,0.006306931842118502],[121,97,0.010096673853695393],[122,97,0.00807521678507328],[123,97,0.004375695250928402],[124,97,0.0006761741824448109],[122,98,0.002802216447889805],[123,98,0.006413611583411694],[124,98,0.010025006718933582],[125,98,0.007804089691489935],[126,98,0.004278772044926882],[127,98,0.0007534541073255241],[125,99,0.002812325721606612],[126,99,0.006253630854189396],[127,99,0.009694935753941536],[128,99,0.007789100985974073],[129,99,0.004429795313626528],[130,99,0.0010704898741096258],[128,100,0.0025784021709114313],[129,100,0.005857671145349741],[130,100,0.009136940352618694],[131,100,0.008000868372619152],[132,100,0.004799720831215382],[133,100,0.0015985730569809675],[131,101,0.0021290841978043318],[132,101,0.0052539510652422905],[133,101,0.00837881863117218],[134,101,0.008412200026214123],[135,101,0.005361814517527819],[136,101,0.002311429474502802],[134,102,0.0014907211298123002],[135,102,0.004468441475182772],[136,102,0.007446161471307278],[137,102,0.008998350240290165],[138,102,0.006091524846851826],[139,102,0.00318469968624413],[140,102,0.0002778743510134518],[137,103,0.0006874035461805761],[138,103,0.003524982836097479],[139,103,0.006362562067806721],[140,103,0.009200140833854675],[141,103,0.006966239772737026],[142,103,0.004196294117718935],[143,103,0.0014263488119468093],[141,104,0.002445453079417348],[142,104,0.005149385891854763],[143,104,0.007853318005800247],[144,104,0.007965351454913616],[145,104,0.005325851496309042],[146,104,0.002686351304873824],[147,104,4.6851124352542683e-05],[144,105,0.0012498258147388697],[145,105,0.003826454281806946],[146,105,0.006403082516044378],[147,105,0.00897971075028181],[148,105,0.006554814055562019],[149,105,0.004039536230266094],[150,105,0.001524258521385491],[148,106,0.002411770634353161],[149,106,0.004867123905569315],[150,106,0.007322477176785469],[151,106,0.00786607339978218],[152,106,0.005469250492751598],[153,106,0.0030724273528903723],[154,106,0.0006756045040674508],[151,107,0.0009218427003361285],[152,107,0.003261544741690159],[153,107,0.00560124684125185],[154,107,0.007940948940813541],[155,107,0.006960286758840084],[156,107,0.004676329903304577],[157,107,0.0023923732805997133],[158,107,0.00010841657058335841],[155,108,0.001601085183210671],[156,108,0.003830620553344488],[157,108,0.006060156039893627],[158,108,0.008289691060781479],[159,108,0.006322428584098816],[160,108,0.004146021790802479],[161,108,0.0019696145318448544],[159,109,0.0020237017888575792],[160,109,0.004148253705352545],[161,109,0.006272805389016867],[162,109,0.007998434826731682],[163,109,0.005924496799707413],[164,109,0.0038505580741912127],[165,109,0.0017766198143363],[162,110,0.00019236317893955857],[163,110,0.0022169058211147785],[164,110,0.004241448361426592],[165,110,0.0062659913673996925],[166,110,0.007716970518231392],[167,110,0.005740635562688112],[168,110,0.003764300374314189],[169,110,0.0017879655351862311],[166,111,0.0002765751851256937],[167,111,0.002205819124355912],[168,111,0.0041350629180669785],[169,111,0.006064306478947401],[170,111,0.0076301731169223785],[171,111,0.005746927578002214],[172,111,0.003863682970404625],[173,111,0.001980437897145748],[174,111,9.719309309730306e-05],[170,112,0.0001752191747073084],[171,112,0.002013584366068244],[172,112,0.003851949702948332],[173,112,0.005690314806997776],[174,112,0.0075286803767085075],[175,112,0.005921597592532635],[176,112,0.004127021413296461],[177,112,0.002332444768399],[178,112,0.0005378681817092001],[175,113,0.0016614228952676058],[176,113,0.0034132569562643766],[177,113,0.005165090784430504],[178,113,0.006916924845427275],[179,113,0.006244422402232885],[180,113,0.004534309729933739],[181,113,0.0028241975232958794],[182,113,0.0011140849674120545],[179,114,0.0011690219398587942],[180,114,0.0028383824974298477],[181,114,0.004507742822170258],[182,114,0.006177103146910667],[183,114,0.00669668847694993],[184,114,0.005067131482064724],[185,114,0.0034375751856714487],[186,114,0.001808018540032208],[187,114,0.00017846190894488245],[183,115,0.0005544161540456116],[184,115,0.0021451376378536224],[185,115,0.0037358589470386505],[186,115,0.005326580721884966],[187,115,0.006917302496731281],[188,115,0.005708739627152681],[189,115,0.004155908711254597],[190,115,0.0026030780281871557],[191,115,0.0010502473451197147],[188,116,0.0013498314656317234],[189,116,0.002865670947358012],[190,116,0.004381510429084301],[191,116,0.0058973501436412334],[192,116,0.006443656049668789],[193,116,0.004963928833603859],[194,116,0.0034842020832002163],[195,116,0.00200447509996593],[196,116,0.0005247481749393046],[192,117,0.00046751255285926163],[193,117,0.0019119851058349013],[194,117,0.003356457455083728],[195,117,0.00480093015357852],[196,117,0.006245402619242668],[197,117,0.005847633816301823],[198,117,0.004437577910721302],[199,117,0.003027522237971425],[200,117,0.0016174663323909044],[201,117,0.00020741041225846857],[197,118,0.0008883357513695955],[198,118,0.0022648002486675978],[199,118,0.0036412647459656],[200,118,0.005017729010432959],[201,118,0.006394193507730961],[202,118,0.005450631491839886],[203,118,0.0041069528087973595],[204,118,0.0027632745914161205],[205,118,0.0014195962576195598],[206,118,7.591782923555002e-05],[202,119,0.0011187248164787889],[203,119,0.0024303868412971497],[204,119,0.0037420487497001886],[205,119,0.005053711123764515],[206,119,0.00636537279933691],[207,119,0.0052314260974526405],[208,119,0.003951027989387512],[209,119,0.002670630346983671],[210,119,0.001390232122503221],[211,119,0.00010983415268128738],[207,120,0.0011792670702561736],[208,120,0.002429152838885784],[209,120,0.003679038258269429],[210,120,0.004928923677653074],[211,120,0.00617880979552865],[212,120,0.005170582327991724],[213,120,0.0039504761807620525],[214,120,0.00273036933504045],[215,120,0.001510262954980135],[216,120,0.0002901565458159894],[212,121,0.001088875811547041],[213,121,0.002279920969158411],[214,121,0.003470966126769781],[215,121,0.004662011284381151],[216,121,0.005853056441992521],[217,121,0.005250118672847748],[218,121,0.004087434150278568],[219,121,0.002924750093370676],[220,121,0.001762065920047462],[221,121,0.0005993818631395698],[217,122,0.000865069217979908],[218,122,0.00200005155056715],[219,122,0.0031350336503237486],[220,122,0.004270015750080347],[221,122,0.005404998082667589],[222,122,0.005453326739370823],[223,122,0.004345402121543884],[224,122,0.0032374775037169456],[225,122,0.0021295526530593634],[226,122,0.001021627802401781],[222,123,0.0005239806487224996],[223,123,0.0016055030282586813],[224,123,0.0026870255824178457],[225,123,0.003768547670915723],[226,123,0.004850070457905531],[227,123,0.005765118170529604],[228,123,0.004709359724074602],[229,123,0.0036536010447889566],[230,123,0.002597842598333955],[231,123,0.0015420839190483093],[232,123,0.00048632529797032475],[227,124,8.027452713577077e-05],[228,124,0.0011108804028481245],[229,124,0.002141486154869199],[230,124,0.003172091906890273],[231,124,0.004202697891741991],[232,124,0.005233303643763065],[233,124,0.005165420472621918],[234,124,0.0041593704372644424],[235,124,0.003153321100398898],[236,124,0.00214727153070271],[237,124,0.0011412217281758785],[238,124,0.00013517220213543624],[233,125,0.000529693381395191],[234,125,0.0015117720467969775],[235,125,0.002493850886821747],[236,125,0.0034759296104311943],[237,125,0.004458008334040642],[238,125,0.005440087523311377],[239,125,0.004742166958749294],[240,125,0.0037834877148270607],[241,125,0.002824808470904827],[242,125,0.0018661292269825935],[243,125,0.0009074498666450381],[239,126,0.0008101033745333552],[240,126,0.00174594612326473],[241,126,0.002681788755580783],[242,126,0.0036176315043121576],[243,126,0.004553474020212889],[244,126,0.005390489473938942],[245,126,0.00447693420574069],[246,126,0.003563379170373082],[247,126,0.0026498239021748304],[248,126,0.0017362686339765787],[249,126,0.0008227135404013097],[244,127,4.7655015805503353e-05],[245,127,0.0009394447552040219],[246,127,0.0018312345491722226],[247,127,0.0027230242267251015],[248,127,0.003614814253523946],[249,127,0.004506603814661503],[250,127,0.005223152693361044],[251,127,0.004352618008852005],[252,127,0.0034820837900042534],[253,127,0.0026115491054952145],[254,127,0.0017410146538168192],[255,127,0.0008704800857231021]];
|
|
7
|
+
const WINDOW = Float64Array.from({ length: 400 }, (_, i) => .5 - .5 * Math.cos(2 * Math.PI * i / 399));
|
|
8
|
+
|
|
9
|
+
/** Matches nemo128.onnx: reflected centered STFT, includes its final frame. */
|
|
10
|
+
export function nemoFeatures(audio: Float32Array): { data: Float32Array; frames: number } {
|
|
11
|
+
if (audio.length < 320) throw new Error('TDT audio must contain at least 320 samples (20 ms).');
|
|
12
|
+
const frames = Math.floor(audio.length / 160) + 1;
|
|
13
|
+
const emphasized = new Float32Array(audio.length);
|
|
14
|
+
emphasized[0] = audio[0];
|
|
15
|
+
for (let i = 1; i < audio.length; i++) emphasized[i] = audio[i] - Math.fround(Math.fround(.97) * audio[i - 1]);
|
|
16
|
+
const real = new Float64Array(512), imag = new Float64Array(512);
|
|
17
|
+
const power = new Float32Array(257), data = new Float32Array(128 * frames);
|
|
18
|
+
for (let f = 0; f < frames; f++) {
|
|
19
|
+
real.fill(0); imag.fill(0);
|
|
20
|
+
for (let i = 0; i < 400; i++) {
|
|
21
|
+
let sample = f * 160 + i - 200;
|
|
22
|
+
if (sample < 0) sample = -sample;
|
|
23
|
+
if (sample >= audio.length) sample = 2 * audio.length - 2 - sample;
|
|
24
|
+
real[i + 56] = emphasized[sample] * WINDOW[i];
|
|
25
|
+
}
|
|
26
|
+
fft(real, imag);
|
|
27
|
+
for (let k = 0; k < 257; k++) power[k] = real[k] ** 2 + imag[k] ** 2;
|
|
28
|
+
const bands = new Float64Array(128);
|
|
29
|
+
for (const [k, m, weight] of MEL) bands[m] += power[k] * weight;
|
|
30
|
+
for (let m = 0; m < 128; m++) data[m * frames + f] = Math.log(Math.fround(Math.fround(bands[m]) + 2 ** -24));
|
|
31
|
+
}
|
|
32
|
+
for (let m = 0; m < 128; m++) {
|
|
33
|
+
let mean = 0;
|
|
34
|
+
for (let f = 0; f < frames; f++) mean += data[m * frames + f];
|
|
35
|
+
mean = Math.fround(Math.fround(mean) / frames);
|
|
36
|
+
let variance = 0;
|
|
37
|
+
for (let f = 0; f < frames; f++) variance += Math.fround(data[m * frames + f] - mean) ** 2;
|
|
38
|
+
const std = Math.fround(Math.sqrt(Math.fround(Math.fround(variance) / (frames - 1))));
|
|
39
|
+
const denominator = Math.fround(std + Math.fround(1e-5));
|
|
40
|
+
for (let f = 0; f < frames; f++) data[m * frames + f] = Math.fround(data[m * frames + f] - mean) / denominator;
|
|
41
|
+
}
|
|
42
|
+
return { data, frames };
|
|
43
|
+
}
|
|
@@ -50,7 +50,7 @@ const ALLOWED_HF_REPOS_BY_OWNER = new Map<string, Set<string>>([
|
|
|
50
50
|
// through /hf-transformers/*. Derive those allowlist entries from the SDK's model catalog so the
|
|
51
51
|
// proxy can never drift out of sync with it — a missing entry 403s hosted model loads.
|
|
52
52
|
for (const preset of MODEL_PRESETS) {
|
|
53
|
-
if (preset.backend
|
|
53
|
+
if (preset.backend === "webllm") continue;
|
|
54
54
|
const [owner, ...repoParts] = preset.id.split("/");
|
|
55
55
|
if (!owner || repoParts.length === 0) continue;
|
|
56
56
|
const repos = ALLOWED_HF_REPOS_BY_OWNER.get(owner) ?? new Set<string>();
|