runonweb 0.0.1 → 0.0.2
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 +29 -1
- package/package.json +14 -1
- package/src/classify/index.ts +583 -0
- package/src/classify/request.ts +283 -0
- package/src/classify/sizes.ts +26 -0
- package/src/core/pipeline.ts +5 -0
- package/src/core/progress.ts +7 -1
- package/src/emoji/index.ts +5 -5
- package/src/image/index.ts +2 -0
- package/src/ocr/index.ts +1 -1
- package/src/stt/index.ts +55 -60
- package/src/translate/bergamot.ts +8 -2
- package/src/tts/kitten.ts +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,34 @@ cosineSimilarity(embeddings[0], embeddings[1])
|
|
|
106
106
|
|
|
107
107
|
Base: all-MiniLM-L6-v2 (Apache-2.0). 384 dims, normalized.
|
|
108
108
|
|
|
109
|
+
### Text classification: `runonweb/classify`
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { Classifier } from 'runonweb/classify'
|
|
113
|
+
|
|
114
|
+
const classifier = new Classifier() // Kev-0.8B, ~750 MB, WebGPU or WASM
|
|
115
|
+
// new Classifier({ size: 'large' }) // Kev-4B, ~2.7 GB, about 4× slower, more accurate
|
|
116
|
+
const { answers } = await classifier.classify({
|
|
117
|
+
state: 'Shoes arrived two weeks late and in the wrong size.',
|
|
118
|
+
questions: {
|
|
119
|
+
department: { type: 'choice', instructions: 'Which team should handle this?',
|
|
120
|
+
criteria: { returns: null, shipping: null, billing: null } },
|
|
121
|
+
escalate: { type: 'noul', instructions: 'Does this need urgent human attention?' },
|
|
122
|
+
frustration: { type: 'score', instructions: 'How frustrated is the customer?',
|
|
123
|
+
criteria: ['Calm', 'Frustrated', 'Very angry'] },
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
answers.department // { type: 'choice', choice: 'returns', confidence, probabilities: { returns: 0.50, … } }
|
|
127
|
+
answers.escalate // { type: 'noul', noul: 0.51 } p(yes)
|
|
128
|
+
answers.frustration // { type: 'score', score: 1.21, confidence, legend, probabilities }
|
|
129
|
+
|
|
130
|
+
await classifier.classify(request, { dateFacts: true }) // adds day counts between dates in the state
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Base: [Kev-0.8B](https://huggingface.co/jaredpalmer/kev-0.8b) by Jared Palmer (Apache-2.0), a Jev-style decision model: a LoRA and a pointer head on Qwen3.5-0.8B-Base (Apache-2.0). Requests and answers are TypeSafe's System One shapes (`noul` / `choice` / `score`), so the same JSON works against a Kev or Jev server; `noul()`, `choice()` and `score()` build questions like the Python SDK does. Nothing is generated: the text is read once, each question runs as its own row on the text's cache and a pointer head turns it into one probability per option, calibrated with the checkpoint's temperature (`temperature: 1` for raw logits).
|
|
134
|
+
|
|
135
|
+
runonweb merged the LoRA, exported the backbone with the onnxruntime-genai builder (Gated DeltaNet as `LinearAttention` ops) and quantized it to int4 (Kev-0.8B keeps the DeltaNet layers in int8) (`training/kev-onnx`). WebGPU runs on `onnxruntime-web/webgpu` (the JSEP build only runs 2/4-bit `MatMulNBits`), WASM on `onnxruntime-web/wasm` (the WebGPU build has no CPU `GatherBlockQuantized`). Probabilities differ from the fp32 export by ~0.03 (small) and ~0.04 (large) on average. On sources Kev wasn't trained on, Kev-0.8B scores 0.70 and Kev-4B 0.84 (Jev 0.86); test on your own data.
|
|
136
|
+
|
|
109
137
|
### Translation: `runonweb/translate`
|
|
110
138
|
|
|
111
139
|
```ts
|
|
@@ -124,7 +152,7 @@ const multi = new Translator({ model: 'Xenova/m2m100_418M', from: 'fr', to: 'en'
|
|
|
124
152
|
|
|
125
153
|
Base: Mozilla's [Firefox Translations](https://github.com/mozilla/translations) models (MPL-2.0), the same Marian NMT students Firefox ships. 17–44 MB per pair (int8), 106 direct pairs between English and 58 languages; other pairs pivot through English. They run in a Web Worker through the Bergamot WASM runtime (no COOP/COEP headers needed) and are cached in Cache Storage. `PAIRS` lists every pair with architecture, size and COMET score.
|
|
126
154
|
|
|
127
|
-
Weights come from the runonweb mirror on the Hugging Face Hub by default. To self-host, run `node scripts/translate-models.mjs fetch en-es es-en` (or `--all`) and pass `modelPath` pointing at wherever you upload that folder; the script also copies the runtime so you can pass `runtimePath: '<modelPath>/runtime/'` instead of loading it from jsDelivr. The registry is regenerated from Mozilla's model list with `node scripts/translate-models.mjs registry`.
|
|
155
|
+
Weights come from the runonweb mirror on the Hugging Face Hub ([`midudev/firefox-translations`](https://huggingface.co/midudev/firefox-translations)) by default. To self-host, run `node scripts/translate-models.mjs fetch en-es es-en` (or `--all`) and pass `modelPath` pointing at wherever you upload that folder; the script also copies the runtime so you can pass `runtimePath: '<modelPath>/runtime/'` instead of loading it from jsDelivr. The registry is regenerated from Mozilla's model list with `node scripts/translate-models.mjs registry`.
|
|
128
156
|
|
|
129
157
|
### Text-to-speech: `runonweb/tts`
|
|
130
158
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runonweb",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Free browser ML modules: speech, vision, text. Runs locally with WebGPU or WASM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,16 @@
|
|
|
45
45
|
"import": "./src/ocr/sizes.ts",
|
|
46
46
|
"default": "./src/ocr/sizes.ts"
|
|
47
47
|
},
|
|
48
|
+
"./classify": {
|
|
49
|
+
"types": "./src/classify/index.ts",
|
|
50
|
+
"import": "./src/classify/index.ts",
|
|
51
|
+
"default": "./src/classify/index.ts"
|
|
52
|
+
},
|
|
53
|
+
"./classify/sizes": {
|
|
54
|
+
"types": "./src/classify/sizes.ts",
|
|
55
|
+
"import": "./src/classify/sizes.ts",
|
|
56
|
+
"default": "./src/classify/sizes.ts"
|
|
57
|
+
},
|
|
48
58
|
"./embed": {
|
|
49
59
|
"types": "./src/embed/index.ts",
|
|
50
60
|
"import": "./src/embed/index.ts",
|
|
@@ -114,6 +124,9 @@
|
|
|
114
124
|
"background-removal",
|
|
115
125
|
"ocr",
|
|
116
126
|
"embeddings",
|
|
127
|
+
"classification",
|
|
128
|
+
"zero-shot",
|
|
129
|
+
"kev",
|
|
117
130
|
"translation",
|
|
118
131
|
"tts",
|
|
119
132
|
"emoji",
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo, ResolvedDevice } from '../core/index.ts'
|
|
2
|
+
import { resolveDevice } from '../core/device.ts'
|
|
3
|
+
import { CACHE_NAME } from '../core/cache.ts'
|
|
4
|
+
import {
|
|
5
|
+
toAnswers,
|
|
6
|
+
toRecord,
|
|
7
|
+
validateRequest,
|
|
8
|
+
withDateFacts,
|
|
9
|
+
type Answers,
|
|
10
|
+
type ClassifyRequest,
|
|
11
|
+
type DecisionRecord,
|
|
12
|
+
type Question,
|
|
13
|
+
} from './request.ts'
|
|
14
|
+
|
|
15
|
+
import { CLASSIFY_SIZES, DEFAULT_CLASSIFY_SIZE, type ClassifySize } from './sizes.ts'
|
|
16
|
+
|
|
17
|
+
export * from './request.ts'
|
|
18
|
+
export { CLASSIFY_SIZES, DEFAULT_CLASSIFY_SIZE, type ClassifySize } from './sizes.ts'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default weights: Kev-0.8B (Jared Palmer, Apache-2.0), a Jev-style decision model on Qwen3.5-0.8B-Base,
|
|
22
|
+
* with its LoRA merged in and exported to ONNX by runonweb (`training/kev-onnx`). int4 weights with the
|
|
23
|
+
* Gated DeltaNet layers in int8, fp32 activations: ~735 MB, the same file on WebGPU and WASM.
|
|
24
|
+
* `size: 'large'` loads Kev-4B instead.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_MODEL = CLASSIFY_SIZES[DEFAULT_CLASSIFY_SIZE].model
|
|
27
|
+
|
|
28
|
+
const ORT_WASM = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/'
|
|
29
|
+
|
|
30
|
+
export type ClassifyOptions = {
|
|
31
|
+
/** `small` = Kev-0.8B (default), `large` = Kev-4B: more accurate, several times the download and latency. */
|
|
32
|
+
size?: ClassifySize
|
|
33
|
+
/** Hugging Face repo id, or folder name under `modelPath`. Overrides `size`. */
|
|
34
|
+
model?: string
|
|
35
|
+
/**
|
|
36
|
+
* Base URL the weights are served from, e.g. `/models/`: files are read from `<modelPath>/<model>/`.
|
|
37
|
+
* Omit to download `model` from the Hugging Face Hub.
|
|
38
|
+
*/
|
|
39
|
+
modelPath?: string
|
|
40
|
+
device?: Device
|
|
41
|
+
/**
|
|
42
|
+
* Append the day count between every pair of absolute dates found in the state
|
|
43
|
+
* ("June 26, 2026 is 8 days before July 4, 2026"). Kev can't subtract dates; it can use a stated count.
|
|
44
|
+
* Same as Kev's `KEV_DATE_FACTS=1`. Default false.
|
|
45
|
+
*/
|
|
46
|
+
dateFacts?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Softmax temperature. Defaults to the one fitted for the checkpoint (~2.35 for Kev-0.8B), which
|
|
49
|
+
* calibrates probabilities without changing any answer. `1` gives the raw logits.
|
|
50
|
+
*/
|
|
51
|
+
temperature?: number
|
|
52
|
+
onProgress?: ProgressCallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-call overrides of the constructor options. */
|
|
56
|
+
export type ClassifyRunOptions = Pick<ClassifyOptions, 'dateFacts' | 'temperature'>
|
|
57
|
+
|
|
58
|
+
export type ClassifyResult<Q extends Record<string, Question> = Record<string, Question>> = {
|
|
59
|
+
model: string
|
|
60
|
+
answers: Answers<Q>
|
|
61
|
+
usage: {
|
|
62
|
+
/** State tokens (counted once) plus every question's tokens. */
|
|
63
|
+
inputTokens: number
|
|
64
|
+
}
|
|
65
|
+
latencyMs: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Layout of `kev.json`, written by `training/kev-onnx/assemble.py`. */
|
|
69
|
+
type KevConfig = {
|
|
70
|
+
name: string
|
|
71
|
+
temperature: number
|
|
72
|
+
hidden_size: number
|
|
73
|
+
head_dim: number
|
|
74
|
+
head_file: string
|
|
75
|
+
/** `data` is one external-data file or several (big models are split under 2 GB per file). */
|
|
76
|
+
onnx: { model: string; data: string | string[] }
|
|
77
|
+
tokens: { state: number; question: number; option: number; option_end: number; decide: number }
|
|
78
|
+
max_state_tokens: number
|
|
79
|
+
max_row_tokens: number
|
|
80
|
+
cache: {
|
|
81
|
+
layer_types: ('linear_attention' | 'full_attention')[]
|
|
82
|
+
num_key_value_heads: number
|
|
83
|
+
attention_head_dim: number
|
|
84
|
+
conv_dim: number
|
|
85
|
+
conv_kernel: number
|
|
86
|
+
linear_heads: number
|
|
87
|
+
linear_key_dim: number
|
|
88
|
+
linear_value_dim: number
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Ort = typeof import('onnxruntime-web/webgpu')
|
|
93
|
+
type OrtTensor = import('onnxruntime-web').Tensor
|
|
94
|
+
type OrtSession = import('onnxruntime-web').InferenceSession
|
|
95
|
+
type Tokenizer = { encode: (text: string, opts?: { add_special_tokens?: boolean }) => number[] }
|
|
96
|
+
|
|
97
|
+
type Encoded = { state: number[]; rows: { ids: number[]; decide: number; options: number[] }[] }
|
|
98
|
+
|
|
99
|
+
type Runtime = {
|
|
100
|
+
ort: Ort
|
|
101
|
+
session: OrtSession
|
|
102
|
+
tokenizer: Tokenizer
|
|
103
|
+
config: KevConfig
|
|
104
|
+
head: { qW: Float32Array; qB: Float32Array; kW: Float32Array; kB: Float32Array }
|
|
105
|
+
/** Cache inputs for an empty prefix (zero conv/recurrent states, zero-length KV). */
|
|
106
|
+
empty: Record<string, OrtTensor>
|
|
107
|
+
/** present.* output name → past.* input name. */
|
|
108
|
+
pastFor: Map<string, string>
|
|
109
|
+
device: ResolvedDevice
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Typed questions about a text, answered with calibrated probabilities: yes/no (`noul`),
|
|
114
|
+
* multiple choice (`choice`) and ratings (`score`), all in one request. No text is generated.
|
|
115
|
+
*
|
|
116
|
+
* Runs Kev (github.com/jaredpalmer/kev) in the browser. Requests and answers follow TypeSafe's
|
|
117
|
+
* System One API (`POST /v1/systemone`), so the same JSON works against a Kev or Jev server.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { Classifier } from 'runonweb/classify'
|
|
122
|
+
*
|
|
123
|
+
* const classifier = new Classifier()
|
|
124
|
+
* await classifier.load()
|
|
125
|
+
*
|
|
126
|
+
* const { answers } = await classifier.classify({
|
|
127
|
+
* state: 'Shoes arrived two weeks late and in the wrong size.',
|
|
128
|
+
* questions: {
|
|
129
|
+
* department: { type: 'choice', instructions: 'Which team should handle this?',
|
|
130
|
+
* criteria: { returns: null, shipping: null, billing: null } },
|
|
131
|
+
* escalate: { type: 'noul', instructions: 'Does this need urgent human attention?' },
|
|
132
|
+
* frustration: { type: 'score', instructions: 'How frustrated is the customer?',
|
|
133
|
+
* criteria: ['Calm', 'Frustrated', 'Very angry'] },
|
|
134
|
+
* },
|
|
135
|
+
* })
|
|
136
|
+
* answers.department.choice // 'returns'
|
|
137
|
+
* answers.escalate.noul // p(yes)
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export class Classifier {
|
|
141
|
+
#size: ClassifySize
|
|
142
|
+
#model: string
|
|
143
|
+
#modelPath?: string
|
|
144
|
+
#device: Device
|
|
145
|
+
#dateFacts: boolean
|
|
146
|
+
#temperature?: number
|
|
147
|
+
#onProgress?: ProgressCallback
|
|
148
|
+
#rt: Runtime | null = null
|
|
149
|
+
#loading: Promise<void> | null = null
|
|
150
|
+
#queue: Promise<unknown> = Promise.resolve()
|
|
151
|
+
|
|
152
|
+
constructor(options: ClassifyOptions = {}) {
|
|
153
|
+
this.#size = options.size ?? DEFAULT_CLASSIFY_SIZE
|
|
154
|
+
this.#model = options.model ?? CLASSIFY_SIZES[this.#size].model
|
|
155
|
+
this.#modelPath = options.modelPath
|
|
156
|
+
this.#device = options.device ?? 'auto'
|
|
157
|
+
this.#dateFacts = options.dateFacts ?? false
|
|
158
|
+
this.#temperature = options.temperature
|
|
159
|
+
this.#onProgress = options.onProgress
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get size(): ClassifySize {
|
|
163
|
+
return this.#size
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get device(): ResolvedDevice | null {
|
|
167
|
+
return this.#rt?.device ?? null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Calibration temperature in use (the checkpoint's unless overridden). */
|
|
171
|
+
get temperature(): number | null {
|
|
172
|
+
return this.#temperature ?? this.#rt?.config.temperature ?? null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async load(): Promise<void> {
|
|
176
|
+
if (this.#rt) return
|
|
177
|
+
if (this.#loading) return this.#loading
|
|
178
|
+
this.#loading = (async () => {
|
|
179
|
+
this.#rt = await loadRuntime(this.#baseUrl(), await resolveDevice(this.#device), this.#onProgress)
|
|
180
|
+
})()
|
|
181
|
+
try {
|
|
182
|
+
await this.#loading
|
|
183
|
+
} finally {
|
|
184
|
+
this.#loading = null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Answer every question about `state`. Each question only sees the state and itself;
|
|
190
|
+
* the state is read once and reused for every question.
|
|
191
|
+
*/
|
|
192
|
+
async classify<const Q extends Record<string, Question>>(
|
|
193
|
+
request: ClassifyRequest<Q>,
|
|
194
|
+
options: ClassifyRunOptions = {}
|
|
195
|
+
): Promise<ClassifyResult<Q>> {
|
|
196
|
+
validateRequest(request)
|
|
197
|
+
await this.load()
|
|
198
|
+
const run = this.#queue.then(() => this.#classify(request, options))
|
|
199
|
+
this.#queue = run.catch(() => undefined)
|
|
200
|
+
return run
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async #classify<Q extends Record<string, Question>>(
|
|
204
|
+
request: ClassifyRequest<Q>,
|
|
205
|
+
options: ClassifyRunOptions
|
|
206
|
+
): Promise<ClassifyResult<Q>> {
|
|
207
|
+
const rt = this.#rt
|
|
208
|
+
if (!rt) throw new Error('Classifier was disposed')
|
|
209
|
+
const t0 = performance.now()
|
|
210
|
+
this.#onProgress?.({ status: 'classifying' })
|
|
211
|
+
|
|
212
|
+
const dates = options.dateFacts ?? this.#dateFacts
|
|
213
|
+
const req = dates ? { ...request, state: withDateFacts(request.state) } : request
|
|
214
|
+
const { record, meta } = toRecord(req)
|
|
215
|
+
const enc = encode(rt, record)
|
|
216
|
+
const probs = await scoreRows(rt, enc, options.temperature ?? this.#temperature ?? rt.config.temperature)
|
|
217
|
+
|
|
218
|
+
this.#onProgress?.({ status: 'done' })
|
|
219
|
+
return {
|
|
220
|
+
model: rt.config.name,
|
|
221
|
+
answers: toAnswers(probs, meta) as Answers<Q>,
|
|
222
|
+
usage: { inputTokens: enc.state.length + enc.rows.reduce((n, r) => n + r.ids.length, 0) },
|
|
223
|
+
latencyMs: Math.round(performance.now() - t0),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
dispose(): void {
|
|
228
|
+
const rt = this.#rt
|
|
229
|
+
this.#rt = null
|
|
230
|
+
void this.#queue.finally(() => rt?.session.release())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#baseUrl(): string {
|
|
234
|
+
if (this.#modelPath) {
|
|
235
|
+
const base = new URL(this.#modelPath.replace(/\/?$/, '/'), globalThis.location?.href).href
|
|
236
|
+
return `${base}${this.#model}/`
|
|
237
|
+
}
|
|
238
|
+
return `https://huggingface.co/${this.#model}/resolve/main/`
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** One-shot helper: load, classify, dispose. Keep a `Classifier` around for more than one call. */
|
|
243
|
+
export async function classify<const Q extends Record<string, Question>>(
|
|
244
|
+
request: ClassifyRequest<Q>,
|
|
245
|
+
options?: ClassifyOptions
|
|
246
|
+
): Promise<ClassifyResult<Q>> {
|
|
247
|
+
const classifier = new Classifier(options)
|
|
248
|
+
try {
|
|
249
|
+
return await classifier.classify(request)
|
|
250
|
+
} finally {
|
|
251
|
+
classifier.dispose()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export type { ProgressInfo, Device }
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
258
|
+
// Encoding (kev/model.py `encode`, row form)
|
|
259
|
+
|
|
260
|
+
/** Caller text can never produce delimiter tokens: `<|name|>` is rewritten to `<¦name¦>` before tokenizing. */
|
|
261
|
+
function userTokens(tok: Tokenizer, text: string): number[] {
|
|
262
|
+
return tok.encode(text.replace(/<\|([A-Za-z0-9_]+)\|>/g, '<¦$1¦>'), { add_special_tokens: false })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* `[<state> …state…]`, then per question one row that continues the state:
|
|
267
|
+
* `<q> instructions <opt> option </opt> … <decide>`. Delimiters are rarely used Qwen special tokens.
|
|
268
|
+
*/
|
|
269
|
+
function encode(rt: Runtime, record: DecisionRecord): Encoded {
|
|
270
|
+
const { tokens: T, max_state_tokens, max_row_tokens } = rt.config
|
|
271
|
+
const state = [T.state, ...userTokens(rt.tokenizer, record.state)]
|
|
272
|
+
if (state.length > max_state_tokens) {
|
|
273
|
+
throw new Error(`State is ${state.length} tokens; the limit is ${max_state_tokens} (Kev was trained on up to 384)`)
|
|
274
|
+
}
|
|
275
|
+
const rows = record.questions.map((q) => {
|
|
276
|
+
const ids = [T.question, ...userTokens(rt.tokenizer, q.instr)]
|
|
277
|
+
const options: number[] = []
|
|
278
|
+
for (const o of q.options) {
|
|
279
|
+
ids.push(T.option, ...userTokens(rt.tokenizer, o), T.option_end)
|
|
280
|
+
options.push(ids.length - 1)
|
|
281
|
+
}
|
|
282
|
+
ids.push(T.decide)
|
|
283
|
+
if (state.length + ids.length > max_row_tokens) {
|
|
284
|
+
throw new Error(`State plus one question is ${state.length + ids.length} tokens; the limit is ${max_row_tokens}`)
|
|
285
|
+
}
|
|
286
|
+
return { ids, decide: ids.length - 1, options }
|
|
287
|
+
})
|
|
288
|
+
return { state, rows }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
292
|
+
// Inference
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* onnxruntime-web's WebGPU backend keeps a single global OrtRun slot; overlapping runs from any session
|
|
296
|
+
* fail with "Session already started". Every run in this module goes through one queue.
|
|
297
|
+
*/
|
|
298
|
+
let ortTurn: Promise<void> = Promise.resolve()
|
|
299
|
+
|
|
300
|
+
function takeTurn<T>(fn: () => Promise<T>): Promise<T> {
|
|
301
|
+
const run = ortTurn.then(fn, fn)
|
|
302
|
+
ortTurn = run.then(
|
|
303
|
+
() => undefined,
|
|
304
|
+
() => undefined
|
|
305
|
+
)
|
|
306
|
+
return run
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function int64(values: ArrayLike<number>, dims: number[], ort: Ort): OrtTensor {
|
|
310
|
+
const data = new BigInt64Array(values.length)
|
|
311
|
+
for (let i = 0; i < values.length; i++) data[i] = BigInt(values[i]!)
|
|
312
|
+
return new ort.Tensor('int64', data, dims)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** input_ids, attention_mask and the 3-axis (M-RoPE) position ids for `ids` continuing `start` cached tokens. */
|
|
316
|
+
function tokenFeeds(ort: Ort, ids: number[], start: number): Record<string, OrtTensor> {
|
|
317
|
+
const L = ids.length
|
|
318
|
+
const pos = new Array<number>(3 * L)
|
|
319
|
+
for (let a = 0; a < 3; a++) for (let i = 0; i < L; i++) pos[a * L + i] = start + i
|
|
320
|
+
return {
|
|
321
|
+
input_ids: int64(ids, [1, L], ort),
|
|
322
|
+
attention_mask: int64(new Array<number>(start + L).fill(1), [1, start + L], ort),
|
|
323
|
+
position_ids: int64(pos, [3, 1, L], ort),
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Pointer head: `<decide>` vs each `</opt>`, `(k · q) / sqrt(d) / T`, softmax. */
|
|
328
|
+
function readout(rt: Runtime, hidden: Float32Array, row: Encoded['rows'][number], temperature: number): Float64Array {
|
|
329
|
+
const H = rt.config.hidden_size
|
|
330
|
+
const D = rt.config.head_dim
|
|
331
|
+
const project = (W: Float32Array, b: Float32Array, at: number) => {
|
|
332
|
+
const out = new Float64Array(D)
|
|
333
|
+
const off = at * H
|
|
334
|
+
for (let i = 0; i < D; i++) {
|
|
335
|
+
let s = b[i]!
|
|
336
|
+
const w = i * H
|
|
337
|
+
for (let j = 0; j < H; j++) s += W[w + j]! * hidden[off + j]!
|
|
338
|
+
out[i] = s
|
|
339
|
+
}
|
|
340
|
+
return out
|
|
341
|
+
}
|
|
342
|
+
const q = project(rt.head.qW, rt.head.qB, row.decide)
|
|
343
|
+
const scale = 1 / Math.sqrt(D) / temperature
|
|
344
|
+
const z = row.options.map((at) => {
|
|
345
|
+
const k = project(rt.head.kW, rt.head.kB, at)
|
|
346
|
+
let dot = 0
|
|
347
|
+
for (let i = 0; i < D; i++) dot += k[i]! * q[i]!
|
|
348
|
+
return dot * scale
|
|
349
|
+
})
|
|
350
|
+
const max = Math.max(...z)
|
|
351
|
+
const e = z.map((v) => Math.exp(v - max))
|
|
352
|
+
const sum = e.reduce((a, b) => a + b, 0)
|
|
353
|
+
return Float64Array.from(e, (v) => v / sum)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** State pass once, then each question's row on the state's cache (exact: rows are independent). */
|
|
357
|
+
async function scoreRows(rt: Runtime, enc: Encoded, temperature: number): Promise<Float64Array[]> {
|
|
358
|
+
const { ort, session } = rt
|
|
359
|
+
const Ls = enc.state.length
|
|
360
|
+
const owned: OrtTensor[] = []
|
|
361
|
+
const dropOutputs = (out: Record<string, OrtTensor>, keep?: Set<string>) => {
|
|
362
|
+
for (const [name, t] of Object.entries(out)) if (name !== 'hidden_states' && !keep?.has(name)) disposeTensor(t)
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const stateOut = await takeTurn(() => session.run({ ...tokenFeeds(ort, enc.state, 0), ...rt.empty }))
|
|
366
|
+
disposeTensor(stateOut.hidden_states)
|
|
367
|
+
const past: Record<string, OrtTensor> = {}
|
|
368
|
+
for (const [present, pastName] of rt.pastFor) {
|
|
369
|
+
const t = stateOut[present]
|
|
370
|
+
if (!t) throw new Error(`Model output ${present} is missing`)
|
|
371
|
+
past[pastName] = t
|
|
372
|
+
owned.push(t)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const probs: Float64Array[] = []
|
|
376
|
+
for (const row of enc.rows) {
|
|
377
|
+
const out = await takeTurn(() => session.run({ ...tokenFeeds(ort, row.ids, Ls), ...past }))
|
|
378
|
+
try {
|
|
379
|
+
const hidden = (await out.hidden_states!.getData()) as Float32Array
|
|
380
|
+
probs.push(readout(rt, hidden, row, temperature))
|
|
381
|
+
} finally {
|
|
382
|
+
disposeTensor(out.hidden_states)
|
|
383
|
+
dropOutputs(out)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return probs
|
|
387
|
+
} finally {
|
|
388
|
+
for (const t of owned) disposeTensor(t)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function disposeTensor(t: OrtTensor | undefined): void {
|
|
393
|
+
// GPU-resident outputs hold a GPUBuffer until disposed; CPU tensors ignore this.
|
|
394
|
+
if (t && t.location === 'gpu-buffer') t.dispose()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
398
|
+
// Loading
|
|
399
|
+
|
|
400
|
+
async function loadRuntime(base: string, device: ResolvedDevice, onProgress?: ProgressCallback): Promise<Runtime> {
|
|
401
|
+
onProgress?.({ status: 'loading', progress: 0 })
|
|
402
|
+
const config = (await (await fetchCached(`${base}kev.json`)).json()) as KevConfig
|
|
403
|
+
|
|
404
|
+
const dataFiles = typeof config.onnx.data === 'string' ? [config.onnx.data] : config.onnx.data
|
|
405
|
+
const files = [config.onnx.model, config.head_file, 'tokenizer.json', 'tokenizer_config.json', ...dataFiles]
|
|
406
|
+
const progress = new ProgressTotals(onProgress)
|
|
407
|
+
const [model, head, tokenizerJson, tokenizerConfig, ...data] = await Promise.all(
|
|
408
|
+
files.map((file) => fetchBytes(`${base}${file}`, file, progress))
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const { PreTrainedTokenizer } = await import('@huggingface/transformers')
|
|
412
|
+
const text = new TextDecoder()
|
|
413
|
+
const tokenizer = new PreTrainedTokenizer(
|
|
414
|
+
JSON.parse(text.decode(tokenizerJson)),
|
|
415
|
+
JSON.parse(text.decode(tokenizerConfig))
|
|
416
|
+
) as unknown as Tokenizer
|
|
417
|
+
|
|
418
|
+
const D = config.head_dim
|
|
419
|
+
const H = config.hidden_size
|
|
420
|
+
const floats = new Float32Array(head!.buffer, head!.byteOffset, head!.byteLength / 4)
|
|
421
|
+
if (floats.length !== 2 * (D * H + D)) throw new Error('head.bin does not match kev.json')
|
|
422
|
+
const headW = {
|
|
423
|
+
qW: floats.subarray(0, D * H),
|
|
424
|
+
qB: floats.subarray(D * H, D * H + D),
|
|
425
|
+
kW: floats.subarray(D * H + D, 2 * D * H + D),
|
|
426
|
+
kB: floats.subarray(2 * D * H + D),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
onProgress?.({ status: 'initializing' })
|
|
430
|
+
const { cache } = config
|
|
431
|
+
const pastFor = new Map<string, string>()
|
|
432
|
+
cache.layer_types.forEach((type, i) => {
|
|
433
|
+
if (type === 'full_attention') {
|
|
434
|
+
for (const kv of ['key', 'value']) pastFor.set(`present.${i}.${kv}`, `past_key_values.${i}.${kv}`)
|
|
435
|
+
} else {
|
|
436
|
+
pastFor.set(`present.${i}.conv`, `past.${i}.conv`)
|
|
437
|
+
pastFor.set(`present.${i}.recurrent`, `past.${i}.recurrent`)
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
// Zero conv/recurrent states and zero-length KV: the cache of an empty prefix.
|
|
441
|
+
const emptyCache = (ort: Ort) => {
|
|
442
|
+
const empty: Record<string, OrtTensor> = {}
|
|
443
|
+
const zeros = (dims: number[]) => new ort.Tensor('float32', new Float32Array(dims.reduce((a, b) => a * b)), dims)
|
|
444
|
+
cache.layer_types.forEach((type, i) => {
|
|
445
|
+
if (type === 'full_attention') {
|
|
446
|
+
for (const kv of ['key', 'value']) {
|
|
447
|
+
empty[`past_key_values.${i}.${kv}`] = zeros([1, cache.num_key_value_heads, 0, cache.attention_head_dim])
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
empty[`past.${i}.conv`] = zeros([1, cache.conv_dim, cache.conv_kernel])
|
|
451
|
+
empty[`past.${i}.recurrent`] = zeros([1, cache.linear_heads, cache.linear_key_dim, cache.linear_value_dim])
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
return empty
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// External data is referenced by file name, relative to the .onnx file.
|
|
458
|
+
const externalData = dataFiles.map((file, i) => ({ path: file.split('/').pop()!, data: data[i]! }))
|
|
459
|
+
const create = async (ep: ResolvedDevice) => {
|
|
460
|
+
const ort = await loadOrt(ep)
|
|
461
|
+
const session = await ort.InferenceSession.create(model!, {
|
|
462
|
+
executionProviders: [ep],
|
|
463
|
+
externalData,
|
|
464
|
+
graphOptimizationLevel: 'all',
|
|
465
|
+
// Errors only: node-placement warnings ("Some nodes were not assigned…") are expected for shape ops.
|
|
466
|
+
logSeverityLevel: 3,
|
|
467
|
+
// Keep the state's cache on the GPU between the state pass and the question rows.
|
|
468
|
+
...(ep === 'webgpu'
|
|
469
|
+
? { preferredOutputLocation: Object.fromEntries([...pastFor.keys()].map((n) => [n, 'gpu-buffer' as const])) }
|
|
470
|
+
: {}),
|
|
471
|
+
})
|
|
472
|
+
return { ort, session, empty: emptyCache(ort) }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let runtime: Awaited<ReturnType<typeof create>>
|
|
476
|
+
let resolved = device
|
|
477
|
+
try {
|
|
478
|
+
runtime = await create(device)
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (device === 'wasm') throw err
|
|
481
|
+
console.warn('[runonweb/classify] WebGPU session failed, falling back to WASM:', err)
|
|
482
|
+
resolved = 'wasm'
|
|
483
|
+
runtime = await create('wasm')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
onProgress?.({ status: 'ready', progress: 100 })
|
|
487
|
+
return { ...runtime, tokenizer, config, head: headW, pastFor, device: resolved }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ortModules = new Map<ResolvedDevice, Promise<Ort>>()
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* One ONNX Runtime Web build per backend. `onnxruntime-web/webgpu` is the native WebGPU EP: it runs 8-bit
|
|
494
|
+
* MatMulNBits, which the JSEP build in the default entry rejects, but its CPU side lacks GatherBlockQuantized
|
|
495
|
+
* (the int4 embeddings). `onnxruntime-web/wasm` has every CPU kernel the model needs.
|
|
496
|
+
*/
|
|
497
|
+
function loadOrt(ep: ResolvedDevice): Promise<Ort> {
|
|
498
|
+
let mod = ortModules.get(ep)
|
|
499
|
+
if (!mod) {
|
|
500
|
+
mod = (ep === 'webgpu' ? import('onnxruntime-web/webgpu') : import('onnxruntime-web/wasm')).then((ort) => {
|
|
501
|
+
if (typeof document !== 'undefined') ort.env.wasm.wasmPaths = ORT_WASM
|
|
502
|
+
return ort as Ort
|
|
503
|
+
})
|
|
504
|
+
ortModules.set(ep, mod)
|
|
505
|
+
}
|
|
506
|
+
return mod
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
class ProgressTotals {
|
|
510
|
+
#loaded = new Map<string, number>()
|
|
511
|
+
#total = new Map<string, number>()
|
|
512
|
+
#cb?: ProgressCallback
|
|
513
|
+
|
|
514
|
+
constructor(cb?: ProgressCallback) {
|
|
515
|
+
this.#cb = cb
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
update(file: string, loaded: number, total: number) {
|
|
519
|
+
this.#loaded.set(file, loaded)
|
|
520
|
+
this.#total.set(file, total)
|
|
521
|
+
if (!this.#cb) return
|
|
522
|
+
this.#cb({ status: 'progress', file, loaded, total, progress: total ? (loaded / total) * 100 : undefined })
|
|
523
|
+
let l = 0
|
|
524
|
+
let t = 0
|
|
525
|
+
for (const v of this.#loaded.values()) l += v
|
|
526
|
+
for (const v of this.#total.values()) t += v
|
|
527
|
+
if (t) this.#cb({ status: 'progress_total', loaded: l, total: t, progress: (l / t) * 100 })
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function openCache(): Promise<Cache | null> {
|
|
532
|
+
if (typeof caches === 'undefined') return null
|
|
533
|
+
return caches.open(CACHE_NAME).catch(() => null)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function fetchCached(url: string): Promise<Response> {
|
|
537
|
+
const cache = await openCache()
|
|
538
|
+
const hit = await cache?.match(url)
|
|
539
|
+
if (hit) return hit
|
|
540
|
+
const res = await fetch(url)
|
|
541
|
+
if (!res.ok) throw new Error(`Could not load ${url} (${res.status})`)
|
|
542
|
+
await cache?.put(url, res.clone()).catch(() => undefined)
|
|
543
|
+
return res
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Download into one buffer with progress, served from and stored in the shared model cache. */
|
|
547
|
+
async function fetchBytes(url: string, file: string, progress: ProgressTotals): Promise<Uint8Array> {
|
|
548
|
+
const cache = await openCache()
|
|
549
|
+
const hit = await cache?.match(url)
|
|
550
|
+
if (hit) {
|
|
551
|
+
const bytes = new Uint8Array(await hit.arrayBuffer())
|
|
552
|
+
progress.update(file, bytes.byteLength, bytes.byteLength)
|
|
553
|
+
return bytes
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const res = await fetch(url)
|
|
557
|
+
if (!res.ok) throw new Error(`Could not load ${file} (${res.status})`)
|
|
558
|
+
const total = Number(res.headers.get('content-length') ?? 0)
|
|
559
|
+
let bytes: Uint8Array
|
|
560
|
+
if (!res.body) {
|
|
561
|
+
bytes = new Uint8Array(await res.arrayBuffer())
|
|
562
|
+
} else {
|
|
563
|
+
const reader = res.body.getReader()
|
|
564
|
+
let buf = new Uint8Array(total || 1 << 20)
|
|
565
|
+
let received = 0
|
|
566
|
+
for (;;) {
|
|
567
|
+
const { done, value } = await reader.read()
|
|
568
|
+
if (done) break
|
|
569
|
+
if (received + value.byteLength > buf.byteLength) {
|
|
570
|
+
const grown = new Uint8Array(Math.max(buf.byteLength * 2, received + value.byteLength))
|
|
571
|
+
grown.set(buf.subarray(0, received))
|
|
572
|
+
buf = grown
|
|
573
|
+
}
|
|
574
|
+
buf.set(value, received)
|
|
575
|
+
received += value.byteLength
|
|
576
|
+
progress.update(file, received, total || received)
|
|
577
|
+
}
|
|
578
|
+
bytes = received === buf.byteLength ? buf : buf.slice(0, received)
|
|
579
|
+
}
|
|
580
|
+
progress.update(file, bytes.byteLength, bytes.byteLength)
|
|
581
|
+
await cache?.put(url, new Response(bytes as BodyInit, { headers: { 'content-length': String(bytes.byteLength) } })).catch(() => undefined)
|
|
582
|
+
return bytes
|
|
583
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request / answer shapes for `runonweb/classify`. They follow TypeSafe's System One contract
|
|
3
|
+
* (`POST /v1/systemone`), which is also what Kev serves, so a request written for either runs here unchanged.
|
|
4
|
+
*
|
|
5
|
+
* Ported from `kev/api.py` (github.com/jaredpalmer/kev, Apache-2.0): same rendering of objects to text,
|
|
6
|
+
* same option strings, same answer math. Changing any string here changes what the model reads.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type JSONContent = string | number | boolean | null | undefined | JSONContent[] | { [key: string]: JSONContent }
|
|
10
|
+
|
|
11
|
+
/** Yes/no question. The answer is the probability of yes. */
|
|
12
|
+
export type NoulQuestion = {
|
|
13
|
+
type: 'noul'
|
|
14
|
+
instructions?: JSONContent
|
|
15
|
+
/** Optional descriptions of what counts as yes (`true`) and no (`false`). */
|
|
16
|
+
criteria?: { true?: JSONContent; false?: JSONContent }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pick one option. Keys are the option names; values describe them (or `null`). */
|
|
20
|
+
export type ChoiceQuestion = {
|
|
21
|
+
type: 'choice'
|
|
22
|
+
instructions?: JSONContent
|
|
23
|
+
criteria: Record<string, JSONContent>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rate on an ordered scale. Levels go from lowest to highest. */
|
|
27
|
+
export type ScoreQuestion = {
|
|
28
|
+
type: 'score'
|
|
29
|
+
instructions?: JSONContent
|
|
30
|
+
criteria: JSONContent[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
|
|
34
|
+
|
|
35
|
+
export type ClassifyRequest<Q extends Record<string, Question> = Record<string, Question>> = {
|
|
36
|
+
/** The text to evaluate. Objects and arrays are rendered as labeled text. */
|
|
37
|
+
state: JSONContent
|
|
38
|
+
/** You choose the ids; the model never sees them. Each question only sees the state and itself. */
|
|
39
|
+
questions: Q
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NoulAnswer = { type: 'noul'; noul: number }
|
|
43
|
+
|
|
44
|
+
export type ChoiceAnswer<K extends string = string> = {
|
|
45
|
+
type: 'choice'
|
|
46
|
+
choice: K
|
|
47
|
+
/** `(p_max − 1/K) / (1 − 1/K)`. Not a measured accuracy rate. */
|
|
48
|
+
confidence: number
|
|
49
|
+
probabilities: Record<K, number>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ScoreAnswer = {
|
|
53
|
+
type: 'score'
|
|
54
|
+
/** Expected level index, starting at 0. */
|
|
55
|
+
score: number
|
|
56
|
+
/** How concentrated the distribution is around its most likely level. */
|
|
57
|
+
confidence: number
|
|
58
|
+
legend: Record<string, string>
|
|
59
|
+
probabilities: Record<string, number>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer
|
|
63
|
+
|
|
64
|
+
export type Answers<Q extends Record<string, Question>> = {
|
|
65
|
+
[K in keyof Q]: Q[K] extends NoulQuestion
|
|
66
|
+
? NoulAnswer
|
|
67
|
+
: Q[K] extends ChoiceQuestion
|
|
68
|
+
? ChoiceAnswer<Extract<keyof Q[K]['criteria'], string>>
|
|
69
|
+
: ScoreAnswer
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const MAX_OPTIONS = 255
|
|
73
|
+
|
|
74
|
+
/** Build a yes/no question (same as TypeSafe's `Noul(...)`). */
|
|
75
|
+
export const noul = (instructions: JSONContent, criteria?: NoulQuestion['criteria']): NoulQuestion => ({
|
|
76
|
+
type: 'noul',
|
|
77
|
+
instructions,
|
|
78
|
+
...(criteria ? { criteria } : {}),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** Build a multiple-choice question (same as TypeSafe's `Choice(...)`). */
|
|
82
|
+
export const choice = <const C extends Record<string, JSONContent>>(instructions: JSONContent, criteria: C) => ({
|
|
83
|
+
type: 'choice' as const,
|
|
84
|
+
instructions,
|
|
85
|
+
criteria,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/** Build a rating question (same as TypeSafe's `Score(...)`). */
|
|
89
|
+
export const score = (instructions: JSONContent, criteria: JSONContent[]): ScoreQuestion => ({
|
|
90
|
+
type: 'score',
|
|
91
|
+
instructions,
|
|
92
|
+
criteria,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
/** A record the encoder reads: state text plus, per question, instruction text and option strings. */
|
|
96
|
+
export type DecisionRecord = {
|
|
97
|
+
state: string
|
|
98
|
+
questions: { instr: string; options: string[] }[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type QuestionMeta = {
|
|
102
|
+
id: string
|
|
103
|
+
type: Question['type']
|
|
104
|
+
/** Keys probabilities are reported under, in option order. */
|
|
105
|
+
keys: string[]
|
|
106
|
+
legend?: Record<string, string>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isScalar = (v: JSONContent): v is string | number | boolean =>
|
|
110
|
+
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
|
111
|
+
|
|
112
|
+
/** Python's `str()` for the scalars JSON can carry (Kev renders booleans as `True` / `False`). */
|
|
113
|
+
function pyStr(v: string | number | boolean): string {
|
|
114
|
+
if (typeof v === 'boolean') return v ? 'True' : 'False'
|
|
115
|
+
return String(v)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Flatten str | object | array into the text the model sees. Field names are kept as labels. */
|
|
119
|
+
export function render(v: JSONContent, indent = 0): string {
|
|
120
|
+
const pad = ' '.repeat(indent)
|
|
121
|
+
if (v === null || v === undefined) return ''
|
|
122
|
+
if (isScalar(v)) return pyStr(v)
|
|
123
|
+
if (Array.isArray(v)) return v.map((x) => `${pad}- ${render(x, indent + 1).trimStart()}`).join('\n')
|
|
124
|
+
return Object.entries(v)
|
|
125
|
+
.map(([k, x]) =>
|
|
126
|
+
x !== null && typeof x === 'object' ? `${pad}${k}:\n${render(x, indent + 1)}` : `${pad}${k}: ${render(x)}`
|
|
127
|
+
)
|
|
128
|
+
.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function optionText(name: string, desc: JSONContent): string {
|
|
132
|
+
return desc === null || desc === undefined || desc === '' ? name : `${name}: ${render(desc)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const MONTHS = 'January|February|March|April|May|June|July|August|September|October|November|December'
|
|
136
|
+
const DATE_RE = new RegExp(`\\b(?:${MONTHS}) \\d{1,2}, \\d{4}\\b|\\b\\d{4}-\\d{2}-\\d{2}\\b`, 'g')
|
|
137
|
+
|
|
138
|
+
function parseDate(raw: string): number | null {
|
|
139
|
+
let y: number, m: number, d: number
|
|
140
|
+
if (raw.includes(',')) {
|
|
141
|
+
const [month, day, year] = raw.replace(',', '').split(' ')
|
|
142
|
+
m = MONTHS.split('|').indexOf(month!) + 1
|
|
143
|
+
d = Number(day)
|
|
144
|
+
y = Number(year)
|
|
145
|
+
} else {
|
|
146
|
+
;[y, m, d] = raw.split('-').map(Number) as [number, number, number]
|
|
147
|
+
}
|
|
148
|
+
const t = Date.UTC(y, m - 1, d)
|
|
149
|
+
const back = new Date(t)
|
|
150
|
+
if (back.getUTCFullYear() !== y || back.getUTCMonth() !== m - 1 || back.getUTCDate() !== d) return null
|
|
151
|
+
return t / 86_400_000
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Day counts between every pair of absolute dates in `text` ("August 3, 2026 is 12 days after July 22, 2026.").
|
|
156
|
+
* Kev can't subtract dates reliably but uses a stated day count. Empty when fewer than two dates are found.
|
|
157
|
+
*/
|
|
158
|
+
export function dateFacts(text: string): string {
|
|
159
|
+
const found: [string, number][] = []
|
|
160
|
+
for (const m of text.matchAll(DATE_RE)) {
|
|
161
|
+
const raw = m[0]
|
|
162
|
+
const day = parseDate(raw)
|
|
163
|
+
if (day === null) continue
|
|
164
|
+
if (!found.some(([r]) => r === raw)) found.push([raw, day])
|
|
165
|
+
}
|
|
166
|
+
const facts: string[] = []
|
|
167
|
+
for (let i = 0; i < found.length; i++) {
|
|
168
|
+
for (let j = i + 1; j < found.length; j++) {
|
|
169
|
+
const n = found[j]![1] - found[i]![1]
|
|
170
|
+
const a = Math.abs(n)
|
|
171
|
+
facts.push(
|
|
172
|
+
n
|
|
173
|
+
? `${found[j]![0]} is ${a} day${a !== 1 ? 's' : ''} ${n > 0 ? 'after' : 'before'} ${found[i]![0]}.`
|
|
174
|
+
: `${found[j]![0]} is the same day as ${found[i]![0]}.`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return facts.join(' ')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** State with a `date_facts` field (objects), entry (arrays) or paragraph (text) when two or more dates appear. */
|
|
182
|
+
export function withDateFacts(state: JSONContent): JSONContent {
|
|
183
|
+
const facts = dateFacts(render(state))
|
|
184
|
+
if (!facts) return state
|
|
185
|
+
if (Array.isArray(state)) return [...state, { date_facts: facts }]
|
|
186
|
+
if (state !== null && typeof state === 'object') return { ...state, date_facts: facts }
|
|
187
|
+
return `${render(state)}\n\ndate_facts: ${facts}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function questionKeys(q: Question): string[] {
|
|
191
|
+
if (q.type === 'choice') return Object.keys(q.criteria)
|
|
192
|
+
if (q.type === 'noul') return ['false', 'true']
|
|
193
|
+
return q.criteria.map((_, i) => String(i))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Validate a request the way the Kev server does (it answers 422); throws a readable error instead. */
|
|
197
|
+
export function validateRequest(req: ClassifyRequest): void {
|
|
198
|
+
if (!req || typeof req !== 'object') throw new TypeError('Request must be an object with `state` and `questions`')
|
|
199
|
+
const entries = Object.entries(req.questions ?? {})
|
|
200
|
+
if (entries.length === 0) throw new Error('`questions` needs at least one question')
|
|
201
|
+
for (const [id, q] of entries) {
|
|
202
|
+
if (!q || typeof q !== 'object') throw new Error(`Question "${id}" must be an object`)
|
|
203
|
+
if (q.type === 'choice') {
|
|
204
|
+
const n = Object.keys(q.criteria ?? {}).length
|
|
205
|
+
if (n < 1 || n > MAX_OPTIONS) throw new Error(`Choice "${id}" needs 1–${MAX_OPTIONS} options in \`criteria\``)
|
|
206
|
+
} else if (q.type === 'score') {
|
|
207
|
+
const n = Array.isArray(q.criteria) ? q.criteria.length : 0
|
|
208
|
+
if (n < 1 || n > MAX_OPTIONS) throw new Error(`Score "${id}" needs 1–${MAX_OPTIONS} levels in \`criteria\``)
|
|
209
|
+
} else if (q.type !== 'noul') {
|
|
210
|
+
throw new Error(`Question "${id}" has unknown type "${(q as { type?: string }).type}" (noul | choice | score)`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Request → encoder record plus per-question metadata to map probabilities back. */
|
|
216
|
+
export function toRecord(req: ClassifyRequest): { record: DecisionRecord; meta: QuestionMeta[] } {
|
|
217
|
+
const questions: DecisionRecord['questions'] = []
|
|
218
|
+
const meta: QuestionMeta[] = []
|
|
219
|
+
for (const [id, q] of Object.entries(req.questions)) {
|
|
220
|
+
const m: QuestionMeta = { id, type: q.type, keys: questionKeys(q) }
|
|
221
|
+
let options: string[]
|
|
222
|
+
if (q.type === 'noul') {
|
|
223
|
+
const c = q.criteria ?? {}
|
|
224
|
+
options = [optionText('no', c.false), optionText('yes', c.true)]
|
|
225
|
+
} else if (q.type === 'choice') {
|
|
226
|
+
options = Object.entries(q.criteria).map(([k, v]) => optionText(k, v))
|
|
227
|
+
} else {
|
|
228
|
+
options = q.criteria.map((x) => render(x))
|
|
229
|
+
m.legend = Object.fromEntries(m.keys.map((k, i) => [k, options[i]!]))
|
|
230
|
+
}
|
|
231
|
+
questions.push({ instr: render(q.instructions), options })
|
|
232
|
+
meta.push(m)
|
|
233
|
+
}
|
|
234
|
+
return { record: { state: render(req.state), questions }, meta }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const round = (x: number) => Math.round(x * 10_000) / 10_000
|
|
238
|
+
|
|
239
|
+
export function choiceConfidence(p: ArrayLike<number>): number {
|
|
240
|
+
const K = p.length
|
|
241
|
+
return K === 1 ? 1 : (Math.max(...Array.from(p)) - 1 / K) / (1 - 1 / K)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function scoreConfidence(p: ArrayLike<number>): number {
|
|
245
|
+
const L = p.length
|
|
246
|
+
if (L === 1) return 1
|
|
247
|
+
let mode = 0
|
|
248
|
+
for (let i = 1; i < L; i++) if (p[i]! > p[mode]!) mode = i
|
|
249
|
+
let spread = 0
|
|
250
|
+
for (let i = 0; i < L; i++) spread += p[i]! * Math.abs(i - mode)
|
|
251
|
+
return 1 - spread / (L - 1)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Probabilities per question → System One answers (4-decimal rounding, as served by Kev). */
|
|
255
|
+
export function toAnswers(probs: Float64Array[], meta: QuestionMeta[]): Record<string, Answer> {
|
|
256
|
+
const out: Record<string, Answer> = {}
|
|
257
|
+
probs.forEach((p, qi) => {
|
|
258
|
+
const m = meta[qi]!
|
|
259
|
+
if (m.type === 'noul') {
|
|
260
|
+
out[m.id] = { type: 'noul', noul: round(p[1]!) }
|
|
261
|
+
} else if (m.type === 'choice') {
|
|
262
|
+
let best = 0
|
|
263
|
+
for (let i = 1; i < p.length; i++) if (p[i]! > p[best]!) best = i
|
|
264
|
+
out[m.id] = {
|
|
265
|
+
type: 'choice',
|
|
266
|
+
choice: m.keys[best]!,
|
|
267
|
+
confidence: round(choiceConfidence(p)),
|
|
268
|
+
probabilities: Object.fromEntries(m.keys.map((k, i) => [k, round(p[i]!)])),
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
let s = 0
|
|
272
|
+
for (let i = 0; i < p.length; i++) s += i * p[i]!
|
|
273
|
+
out[m.id] = {
|
|
274
|
+
type: 'score',
|
|
275
|
+
score: round(s),
|
|
276
|
+
confidence: round(scoreConfidence(p)),
|
|
277
|
+
legend: m.legend ?? {},
|
|
278
|
+
probabilities: Object.fromEntries(m.keys.map((k, i) => [k, round(p[i]!)])),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type ClassifySize = 'small' | 'large'
|
|
2
|
+
|
|
3
|
+
/** Kev checkpoints packaged for the browser (`training/kev-onnx`). Accuracy is Kev's locked test on sources it never trained on. */
|
|
4
|
+
export const CLASSIFY_SIZES: Record<
|
|
5
|
+
ClassifySize,
|
|
6
|
+
{ label: string; model: string; base: string; params: string; downloadMB: string; outOfDomain: string }
|
|
7
|
+
> = {
|
|
8
|
+
small: {
|
|
9
|
+
label: 'Small',
|
|
10
|
+
model: 'midudev/kev-0.8b-ONNX',
|
|
11
|
+
base: 'Kev-0.8B',
|
|
12
|
+
params: '0.8B',
|
|
13
|
+
downloadMB: '~750 MB',
|
|
14
|
+
outOfDomain: '0.70',
|
|
15
|
+
},
|
|
16
|
+
large: {
|
|
17
|
+
label: 'Large',
|
|
18
|
+
model: 'midudev/kev-4b-ONNX',
|
|
19
|
+
base: 'Kev-4B',
|
|
20
|
+
params: '4B',
|
|
21
|
+
downloadMB: '~2.7 GB',
|
|
22
|
+
outOfDomain: '0.84',
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_CLASSIFY_SIZE: ClassifySize = 'small'
|
package/src/core/pipeline.ts
CHANGED
|
@@ -46,6 +46,11 @@ export async function loadPipeline(options: PipelineLoadOptions): Promise<Loaded
|
|
|
46
46
|
options.onProgress?.({ status: 'loading', progress: 0 })
|
|
47
47
|
|
|
48
48
|
const { pipeline, env } = await import('@huggingface/transformers')
|
|
49
|
+
// The threaded WASM build crashes with "memory access out of bounds" when the
|
|
50
|
+
// page is not cross-origin isolated (no SharedArrayBuffer). It happens at the
|
|
51
|
+
// end of the first download and a reload works. One thread avoids the spawn.
|
|
52
|
+
const wasm = env.backends?.onnx?.wasm
|
|
53
|
+
if (wasm && !globalThis.crossOriginIsolated) wasm.numThreads = 1
|
|
49
54
|
if (options.modelPath) {
|
|
50
55
|
env.allowLocalModels = true
|
|
51
56
|
env.allowRemoteModels = false
|
package/src/core/progress.ts
CHANGED
|
@@ -2,6 +2,10 @@ export type ProgressInfo = {
|
|
|
2
2
|
status: string
|
|
3
3
|
progress?: number
|
|
4
4
|
file?: string
|
|
5
|
+
/** Bytes received for this file, or for every file when `status` is `progress_total`. */
|
|
6
|
+
loaded?: number
|
|
7
|
+
/** Total bytes expected for the same scope as `loaded`. */
|
|
8
|
+
total?: number
|
|
5
9
|
}
|
|
6
10
|
|
|
7
11
|
export type ProgressCallback = (info: ProgressInfo) => void
|
|
@@ -16,5 +20,7 @@ export function toProgressInfo(data: Record<string, unknown>): ProgressInfo {
|
|
|
16
20
|
? (data.loaded / data.total) * 100
|
|
17
21
|
: undefined
|
|
18
22
|
const file = typeof data.file === 'string' ? data.file : undefined
|
|
19
|
-
|
|
23
|
+
const loaded = typeof data.loaded === 'number' ? data.loaded : undefined
|
|
24
|
+
const total = typeof data.total === 'number' ? data.total : undefined
|
|
25
|
+
return { status, progress, file, loaded, total }
|
|
20
26
|
}
|
package/src/emoji/index.ts
CHANGED
|
@@ -4,16 +4,16 @@ import { loadPipeline } from '../core/pipeline.ts'
|
|
|
4
4
|
/**
|
|
5
5
|
* Default weights: `text2emoji-tiny`, a 2.4M-parameter T5 trained from scratch for runonweb
|
|
6
6
|
* on the Text2Emoji dataset. 3.9 MB as q8 (encoder 1.7 MB + decoder 2.1 MB), MIT.
|
|
7
|
-
* Trained with `training/text2emoji` in the runonweb repo.
|
|
7
|
+
* Trained with `training/text2emoji` in the runonweb repo. Published on the Hub as `midudev/text2emoji-tiny`.
|
|
8
8
|
*/
|
|
9
|
-
export const DEFAULT_MODEL = 'text2emoji-tiny'
|
|
9
|
+
export const DEFAULT_MODEL = 'midudev/text2emoji-tiny'
|
|
10
10
|
|
|
11
11
|
export type EmojiOptions = {
|
|
12
12
|
/** Model folder name (under `modelPath`) or Hugging Face repo id. */
|
|
13
13
|
model?: string
|
|
14
14
|
/**
|
|
15
|
-
* Base URL the weights are served from, e.g. `/models
|
|
16
|
-
* in `public/models/`. Omit to download `model` from the
|
|
15
|
+
* Base URL the weights are served from, e.g. `/models/`: files are read from `<modelPath>/<model>/`.
|
|
16
|
+
* The runonweb site self-hosts them in `public/models/text2emoji-tiny/`. Omit to download `model` from the Hub.
|
|
17
17
|
*/
|
|
18
18
|
modelPath?: string
|
|
19
19
|
device?: Device
|
|
@@ -36,7 +36,7 @@ export type EmojiResult = {
|
|
|
36
36
|
* ```ts
|
|
37
37
|
* import { Emojifier } from 'runonweb/emoji'
|
|
38
38
|
*
|
|
39
|
-
* const emojifier = new Emojifier(
|
|
39
|
+
* const emojifier = new Emojifier()
|
|
40
40
|
* await emojifier.load()
|
|
41
41
|
* const { text } = await emojifier.emojify('I love pizza and my dog')
|
|
42
42
|
* // "🍕❤️🐶"
|
package/src/image/index.ts
CHANGED
|
@@ -85,6 +85,8 @@ function mapLoadProgress(onProgress?: ProgressCallback): (status: Record<string,
|
|
|
85
85
|
status: component ? `loading ${component}` : 'loading',
|
|
86
86
|
progress: progress * 100,
|
|
87
87
|
file: component,
|
|
88
|
+
loaded: loaded > 0 ? loaded : undefined,
|
|
89
|
+
total: total > 0 ? total : undefined,
|
|
88
90
|
})
|
|
89
91
|
}
|
|
90
92
|
}
|
package/src/ocr/index.ts
CHANGED
|
@@ -324,7 +324,7 @@ async function fetchCached(
|
|
|
324
324
|
if (done) break
|
|
325
325
|
chunks.push(value)
|
|
326
326
|
received += value.byteLength
|
|
327
|
-
onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
|
|
327
|
+
onProgress?.({ status: 'progress', progress: (received / total) * 100, file, loaded: received, total })
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
const out = new Uint8Array(received)
|
package/src/stt/index.ts
CHANGED
|
@@ -36,6 +36,14 @@ export type TranscribeOptions = {
|
|
|
36
36
|
* `translate` turns speech into English. Never the default here.
|
|
37
37
|
*/
|
|
38
38
|
task?: 'transcribe' | 'translate'
|
|
39
|
+
/**
|
|
40
|
+
* Ask Whisper for timestamps. `"segment"` returns each phrase with the model's
|
|
41
|
+
* start and end, in seconds. `"word"` (or `true`) splits those phrases into
|
|
42
|
+
* words and keeps them inside the phrase span. This ONNX build has no
|
|
43
|
+
* cross-attentions, so it cannot time each word on its own. Omit it and
|
|
44
|
+
* `chunks` is empty.
|
|
45
|
+
*/
|
|
46
|
+
timestamps?: boolean | 'word' | 'segment'
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export type STTAudioInput = Blob | File | string | Float32Array
|
|
@@ -117,8 +125,8 @@ export class SpeechToText {
|
|
|
117
125
|
this.#onProgress?.({ status: 'transcribing' })
|
|
118
126
|
|
|
119
127
|
const input = await prepareAudio(audio)
|
|
120
|
-
const duration = input instanceof Float32Array ? input.length / SAMPLE_RATE : 0
|
|
121
128
|
const onPartial = options?.onPartial
|
|
129
|
+
const timestampMode = resolveTimestamps(options?.timestamps)
|
|
122
130
|
let streamed = ''
|
|
123
131
|
|
|
124
132
|
const base: Record<string, unknown> = {
|
|
@@ -127,15 +135,14 @@ export class SpeechToText {
|
|
|
127
135
|
// Always transcribe unless asked otherwise. Multilingual Whisper will
|
|
128
136
|
// otherwise slip into "translate to English" when language is unknown.
|
|
129
137
|
task: options?.task ?? 'transcribe',
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// can come back empty after the streamer already showed text.
|
|
138
|
+
// Without this, a clip that ends mid-word throws and the retry can come
|
|
139
|
+
// back empty after the streamer already showed text.
|
|
133
140
|
force_full_sequences: false,
|
|
134
141
|
}
|
|
135
142
|
const language = options?.language ?? this.#language
|
|
136
143
|
if (language) base.language = language
|
|
137
144
|
|
|
138
|
-
const run = async (
|
|
145
|
+
const run = async (stream: boolean) => {
|
|
139
146
|
const streamer =
|
|
140
147
|
stream && onPartial ? await createStreamer(this.#pipe!, (text) => {
|
|
141
148
|
streamed = text
|
|
@@ -143,24 +150,26 @@ export class SpeechToText {
|
|
|
143
150
|
}) : undefined
|
|
144
151
|
return this.#pipe!(input, {
|
|
145
152
|
...base,
|
|
146
|
-
return_timestamps:
|
|
153
|
+
return_timestamps: timestampMode ? true : false,
|
|
147
154
|
...(streamer ? { streamer } : {}),
|
|
148
155
|
})
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
let raw: RawResult | RawResult[]
|
|
152
159
|
try {
|
|
153
|
-
|
|
154
|
-
// need a different generate() return shape and often fail once a streamer
|
|
155
|
-
// is attached, wiping the final text after a good partial stream.
|
|
156
|
-
raw = await run(true, true)
|
|
160
|
+
raw = await run(true)
|
|
157
161
|
} catch {
|
|
158
|
-
raw = await run(
|
|
162
|
+
raw = await run(false)
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
const parsed = normalizeRaw(raw)
|
|
162
166
|
const text = parsed.text || streamed
|
|
163
|
-
const chunks =
|
|
167
|
+
const chunks =
|
|
168
|
+
timestampMode === 'word'
|
|
169
|
+
? wordsInsideSegments(parsed.chunks)
|
|
170
|
+
: timestampMode === 'segment'
|
|
171
|
+
? parsed.chunks
|
|
172
|
+
: []
|
|
164
173
|
|
|
165
174
|
this.#onProgress?.({ status: 'done' })
|
|
166
175
|
return { text, chunks }
|
|
@@ -190,6 +199,38 @@ export async function transcribe(
|
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
function resolveTimestamps(option: TranscribeOptions['timestamps']): 'word' | 'segment' | false {
|
|
203
|
+
if (option === true || option === 'word') return 'word'
|
|
204
|
+
if (option === 'segment') return 'segment'
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Place words inside the phrase span Whisper actually predicted. */
|
|
209
|
+
function wordsInsideSegments(segments: STTChunk[]): STTChunk[] {
|
|
210
|
+
const words: STTChunk[] = []
|
|
211
|
+
for (const segment of segments) {
|
|
212
|
+
const parts = segment.text.trim().split(/\s+/).filter(Boolean)
|
|
213
|
+
if (!parts.length) continue
|
|
214
|
+
const start = segment.start
|
|
215
|
+
const end = Math.max(segment.end, start)
|
|
216
|
+
if (parts.length === 1) {
|
|
217
|
+
words.push({ text: parts[0]!, start, end })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
const weights = parts.map((part) => Math.max(1, part.length))
|
|
221
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
|
222
|
+
const span = end - start
|
|
223
|
+
let cursor = start
|
|
224
|
+
parts.forEach((text, i) => {
|
|
225
|
+
const dur = span * (weights[i]! / total)
|
|
226
|
+
const next = i === parts.length - 1 ? end : cursor + dur
|
|
227
|
+
words.push({ text, start: cursor, end: next })
|
|
228
|
+
cursor = next
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
return words
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
async function createStreamer(pipe: ASRPipeline, onPartial: (text: string) => void) {
|
|
194
235
|
const { WhisperTextStreamer } = await import('@huggingface/transformers')
|
|
195
236
|
let acc = ''
|
|
@@ -218,8 +259,8 @@ function parseChunks(raw?: RawChunk[]): STTChunk[] {
|
|
|
218
259
|
if (!raw?.length) return []
|
|
219
260
|
const chunks: STTChunk[] = []
|
|
220
261
|
for (const item of raw) {
|
|
221
|
-
const text = (item.text ?? '').replace(/\s+/g, ' ')
|
|
222
|
-
if (!text
|
|
262
|
+
const text = (item.text ?? '').replace(/\s+/g, ' ').trim()
|
|
263
|
+
if (!text) continue
|
|
223
264
|
const start = item.timestamp?.[0] ?? chunks.at(-1)?.end ?? 0
|
|
224
265
|
const end = item.timestamp?.[1] ?? start
|
|
225
266
|
chunks.push({ text, start, end: end < start ? start : end })
|
|
@@ -227,52 +268,6 @@ function parseChunks(raw?: RawChunk[]): STTChunk[] {
|
|
|
227
268
|
return chunks
|
|
228
269
|
}
|
|
229
270
|
|
|
230
|
-
function wordsFromText(text: string, duration: number): STTChunk[] {
|
|
231
|
-
const parts = text.trim().split(/\s+/).filter(Boolean)
|
|
232
|
-
if (!parts.length) return []
|
|
233
|
-
const span = Math.max(0.04, (duration || parts.length * 0.35) / parts.length)
|
|
234
|
-
return parts.map((word, i) => ({
|
|
235
|
-
text: word,
|
|
236
|
-
start: i * span,
|
|
237
|
-
end: (i + 1) * span,
|
|
238
|
-
}))
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Split segment chunks into words and align timestamps to the audio duration. */
|
|
242
|
-
function explodeWords(raw: STTChunk[], duration: number): STTChunk[] {
|
|
243
|
-
const words: STTChunk[] = []
|
|
244
|
-
for (const chunk of raw) {
|
|
245
|
-
const parts = chunk.text.trim().split(/\s+/).filter(Boolean)
|
|
246
|
-
if (!parts.length) continue
|
|
247
|
-
if (parts.length === 1) {
|
|
248
|
-
words.push({ text: parts[0]!, start: chunk.start, end: Math.max(chunk.end, chunk.start) })
|
|
249
|
-
continue
|
|
250
|
-
}
|
|
251
|
-
const start = chunk.start
|
|
252
|
-
const end = Math.max(chunk.end, chunk.start)
|
|
253
|
-
const span = Math.max(0.04, (end - start || parts.length * 0.35) / parts.length)
|
|
254
|
-
parts.forEach((word, i) => {
|
|
255
|
-
words.push({
|
|
256
|
-
text: word,
|
|
257
|
-
start: start + i * span,
|
|
258
|
-
end: start + (i + 1) * span,
|
|
259
|
-
})
|
|
260
|
-
})
|
|
261
|
-
}
|
|
262
|
-
return alignToDuration(words, duration)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function alignToDuration(chunks: STTChunk[], duration: number): STTChunk[] {
|
|
266
|
-
if (!chunks.length) return chunks
|
|
267
|
-
const maxEnd = Math.max(...chunks.map((c) => Math.max(c.end, c.start)))
|
|
268
|
-
if (maxEnd <= 0.05 && duration > 0) return wordsFromText(chunks.map((c) => c.text).join(' '), duration)
|
|
269
|
-
if (duration > 0 && (maxEnd > duration * 1.2 || maxEnd < duration * 0.55)) {
|
|
270
|
-
const scale = duration / maxEnd
|
|
271
|
-
return chunks.map((c) => ({ ...c, start: c.start * scale, end: Math.max(c.end, c.start) * scale }))
|
|
272
|
-
}
|
|
273
|
-
return chunks
|
|
274
|
-
}
|
|
275
|
-
|
|
276
271
|
export type { ProgressInfo, Device }
|
|
277
272
|
|
|
278
273
|
async function prepareAudio(audio: STTAudioInput): Promise<Float32Array | string> {
|
|
@@ -8,7 +8,7 @@ import { WORKER_SOURCE } from './worker.ts'
|
|
|
8
8
|
* Hugging Face Hub (MPL-2.0). Layout: `<modelPath><from>-<to>/<file>?rev=<revision>`.
|
|
9
9
|
* Self-host by pointing `modelPath` at a folder produced by `scripts/translate-models.mjs`.
|
|
10
10
|
*/
|
|
11
|
-
export const DEFAULT_MODEL_PATH = 'https://huggingface.co/
|
|
11
|
+
export const DEFAULT_MODEL_PATH = 'https://huggingface.co/midudev/firefox-translations/resolve/main/'
|
|
12
12
|
|
|
13
13
|
export type BergamotOptions = {
|
|
14
14
|
/** Base URL of the model files. Default: Hugging Face mirror. */
|
|
@@ -110,7 +110,13 @@ async function fetchFile(url: string, file: string, onProgress?: ProgressCallbac
|
|
|
110
110
|
if (!res.ok) throw new Error(`Could not download ${file} (HTTP ${res.status}) from ${url}`)
|
|
111
111
|
|
|
112
112
|
const buffer = await readWithProgress(res, (loaded, total) => {
|
|
113
|
-
onProgress?.({
|
|
113
|
+
onProgress?.({
|
|
114
|
+
status: 'progress',
|
|
115
|
+
file,
|
|
116
|
+
progress: total > 0 ? (loaded / total) * 100 : undefined,
|
|
117
|
+
loaded,
|
|
118
|
+
total: total > 0 ? total : undefined,
|
|
119
|
+
})
|
|
114
120
|
})
|
|
115
121
|
|
|
116
122
|
if (cache) {
|
package/src/tts/kitten.ts
CHANGED
|
@@ -175,7 +175,7 @@ async function fetchCached(url: string, file: string, onProgress?: ProgressCallb
|
|
|
175
175
|
if (done) break
|
|
176
176
|
chunks.push(value)
|
|
177
177
|
received += value.byteLength
|
|
178
|
-
onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
|
|
178
|
+
onProgress?.({ status: 'progress', progress: (received / total) * 100, file, loaded: received, total })
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const out = new Uint8Array(received)
|