runonweb 0.0.1
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 +165 -0
- package/package.json +126 -0
- package/src/caption/index.ts +310 -0
- package/src/clean/index.ts +263 -0
- package/src/core/cache.ts +248 -0
- package/src/core/device.ts +34 -0
- package/src/core/index.ts +18 -0
- package/src/core/pipeline.ts +108 -0
- package/src/core/progress.ts +20 -0
- package/src/depth/index.ts +127 -0
- package/src/detect/index.ts +274 -0
- package/src/embed/index.ts +172 -0
- package/src/emoji/index.ts +132 -0
- package/src/image/engine.d.ts +57 -0
- package/src/image/engine.js +14090 -0
- package/src/image/index.ts +230 -0
- package/src/image/sizes.ts +28 -0
- package/src/ocr/index.ts +416 -0
- package/src/ocr/sizes.ts +12 -0
- package/src/remove-bg/index.ts +170 -0
- package/src/stt/index.ts +320 -0
- package/src/translate/bergamot.ts +280 -0
- package/src/translate/index.ts +240 -0
- package/src/translate/registry.ts +133 -0
- package/src/translate/worker.ts +181 -0
- package/src/tts/index.ts +220 -0
- package/src/tts/kitten.ts +280 -0
- package/src/tts/kokoro.ts +140 -0
- package/src/tts/phonemes.ts +150 -0
- package/src/tts/sizes.ts +30 -0
- package/src/tts/split.ts +31 -0
- package/src/tts/supertonic.ts +126 -0
- package/src/tts/types.ts +11 -0
- package/src/tts/voices.ts +131 -0
- package/src/tts/wav.ts +52 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { ProgressCallback } from '../core/index.ts'
|
|
2
|
+
import { TRANSLATE_CACHE_NAME } from '../core/cache.ts'
|
|
3
|
+
import { PAIRS, RUNTIME_CDN, type PairEntry } from './registry.ts'
|
|
4
|
+
import { WORKER_SOURCE } from './worker.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default weights location: the runonweb mirror of Mozilla's Firefox Translations models on the
|
|
8
|
+
* Hugging Face Hub (MPL-2.0). Layout: `<modelPath><from>-<to>/<file>?rev=<revision>`.
|
|
9
|
+
* Self-host by pointing `modelPath` at a folder produced by `scripts/translate-models.mjs`.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MODEL_PATH = 'https://huggingface.co/runonweb/firefox-translations/resolve/main/'
|
|
12
|
+
|
|
13
|
+
export type BergamotOptions = {
|
|
14
|
+
/** Base URL of the model files. Default: Hugging Face mirror. */
|
|
15
|
+
modelPath?: string
|
|
16
|
+
/** Base URL of `bergamot-translator-worker.{js,wasm}`. Default: jsDelivr. */
|
|
17
|
+
runtimePath?: string
|
|
18
|
+
onProgress?: ProgressCallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Route = PairEntry[]
|
|
22
|
+
|
|
23
|
+
function normalize(code: string): string {
|
|
24
|
+
const c = code.trim()
|
|
25
|
+
if (/^zh[-_]hans$/i.test(c) || /^zh[-_]cn$/i.test(c)) return 'zh'
|
|
26
|
+
if (/^zh[-_]hant$/i.test(c) || /^zh[-_]tw$/i.test(c)) return 'zh-Hant'
|
|
27
|
+
return c.toLowerCase().split(/[-_]/)[0] ?? c
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a language pair to one direct model, or two models pivoting through English. */
|
|
31
|
+
export function resolveRoute(from: string, to: string): Route | null {
|
|
32
|
+
const src = normalize(from)
|
|
33
|
+
const tgt = normalize(to)
|
|
34
|
+
if (src === tgt) return null
|
|
35
|
+
const direct = PAIRS[`${src}-${tgt}`]
|
|
36
|
+
if (direct) return [direct]
|
|
37
|
+
const outbound = PAIRS[`${src}-en`]
|
|
38
|
+
const inbound = PAIRS[`en-${tgt}`]
|
|
39
|
+
if (outbound && inbound) return [outbound, inbound]
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `from → to` can be served, directly or via English. */
|
|
44
|
+
export function hasPair(from: string, to: string): boolean {
|
|
45
|
+
return resolveRoute(from, to) !== null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function keyOf(entry: PairEntry): string {
|
|
49
|
+
return `${entry.from}-${entry.to}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Relative paths are resolved against the page, since the Blob worker has no usable base URL. */
|
|
53
|
+
function absolute(base: string): string {
|
|
54
|
+
const withSlash = base.endsWith('/') ? base : `${base}/`
|
|
55
|
+
if (/^[a-z]+:/i.test(withSlash)) return withSlash
|
|
56
|
+
const origin = typeof location !== 'undefined' ? location.href : 'http://localhost/'
|
|
57
|
+
return new URL(withSlash, origin).href
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function joinUrl(base: string, path: string): string {
|
|
61
|
+
return base + path
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function openCache(): Promise<Cache | null> {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof caches === 'undefined') return null
|
|
67
|
+
return await caches.open(TRANSLATE_CACHE_NAME)
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readWithProgress(
|
|
74
|
+
res: Response,
|
|
75
|
+
onChunk: (loaded: number, total: number) => void
|
|
76
|
+
): Promise<ArrayBuffer> {
|
|
77
|
+
const total = Number(res.headers.get('content-length') ?? 0)
|
|
78
|
+
if (!res.body) return res.arrayBuffer()
|
|
79
|
+
const reader = res.body.getReader()
|
|
80
|
+
const chunks: Uint8Array[] = []
|
|
81
|
+
let loaded = 0
|
|
82
|
+
for (;;) {
|
|
83
|
+
const { done, value } = await reader.read()
|
|
84
|
+
if (done) break
|
|
85
|
+
chunks.push(value)
|
|
86
|
+
loaded += value.byteLength
|
|
87
|
+
onChunk(loaded, total)
|
|
88
|
+
}
|
|
89
|
+
const out = new Uint8Array(loaded)
|
|
90
|
+
let offset = 0
|
|
91
|
+
for (const c of chunks) {
|
|
92
|
+
out.set(c, offset)
|
|
93
|
+
offset += c.byteLength
|
|
94
|
+
}
|
|
95
|
+
return out.buffer
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Download one model file, through Cache Storage so the second load is instant and offline.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchFile(url: string, file: string, onProgress?: ProgressCallback): Promise<ArrayBuffer> {
|
|
102
|
+
const cache = await openCache()
|
|
103
|
+
const hit = await cache?.match(url)
|
|
104
|
+
if (hit) {
|
|
105
|
+
onProgress?.({ status: 'progress', file, progress: 100 })
|
|
106
|
+
return hit.arrayBuffer()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const res = await fetch(url, { credentials: 'omit' })
|
|
110
|
+
if (!res.ok) throw new Error(`Could not download ${file} (HTTP ${res.status}) from ${url}`)
|
|
111
|
+
|
|
112
|
+
const buffer = await readWithProgress(res, (loaded, total) => {
|
|
113
|
+
onProgress?.({ status: 'progress', file, progress: total > 0 ? (loaded / total) * 100 : undefined })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (cache) {
|
|
117
|
+
try {
|
|
118
|
+
await cache.put(
|
|
119
|
+
url,
|
|
120
|
+
new Response(buffer, {
|
|
121
|
+
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
} catch {
|
|
125
|
+
// Quota exceeded or private mode: keep going without a cache.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return buffer
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Bergamot (Marian NMT compiled to WASM) running in a Web Worker, fed with Firefox Translations
|
|
135
|
+
* models. One instance can hold several language pairs.
|
|
136
|
+
*/
|
|
137
|
+
export class BergamotEngine {
|
|
138
|
+
#modelPath: string
|
|
139
|
+
#runtimePath: string
|
|
140
|
+
#onProgress?: ProgressCallback
|
|
141
|
+
#worker: Worker | null = null
|
|
142
|
+
#workerUrl: string | null = null
|
|
143
|
+
#ready: Promise<void> | null = null
|
|
144
|
+
#pending = new Map<number, Pending>()
|
|
145
|
+
#nextId = 1
|
|
146
|
+
#loaded = new Map<string, Promise<void>>()
|
|
147
|
+
|
|
148
|
+
constructor(options: BergamotOptions = {}) {
|
|
149
|
+
this.#modelPath = absolute(options.modelPath ?? DEFAULT_MODEL_PATH)
|
|
150
|
+
this.#runtimePath = absolute(options.runtimePath ?? RUNTIME_CDN)
|
|
151
|
+
this.#onProgress = options.onProgress
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get modelPath(): string {
|
|
155
|
+
return this.#modelPath
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#call<T>(name: string, args?: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
|
|
159
|
+
const worker = this.#worker
|
|
160
|
+
if (!worker) return Promise.reject(new Error('Translator has been disposed'))
|
|
161
|
+
const id = this.#nextId++
|
|
162
|
+
return new Promise<T>((resolve, reject) => {
|
|
163
|
+
this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
164
|
+
worker.postMessage({ id, name, args }, transfer)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Boot the worker and instantiate the WASM runtime. Idempotent. */
|
|
169
|
+
init(): Promise<void> {
|
|
170
|
+
if (this.#ready) return this.#ready
|
|
171
|
+
if (typeof Worker === 'undefined' || typeof Blob === 'undefined') {
|
|
172
|
+
return Promise.reject(new Error('runonweb/translate needs a browser with Web Workers'))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.#workerUrl = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: 'text/javascript' }))
|
|
176
|
+
const worker = new Worker(this.#workerUrl)
|
|
177
|
+
this.#worker = worker
|
|
178
|
+
|
|
179
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
180
|
+
const { id, result, error } = event.data as {
|
|
181
|
+
id: number
|
|
182
|
+
result?: unknown
|
|
183
|
+
error?: { name?: string; message?: string; stack?: string }
|
|
184
|
+
}
|
|
185
|
+
const pending = this.#pending.get(id)
|
|
186
|
+
if (!pending) return
|
|
187
|
+
this.#pending.delete(id)
|
|
188
|
+
if (error) {
|
|
189
|
+
const err = new Error(error.message ?? 'Worker error')
|
|
190
|
+
if (error.name) err.name = error.name
|
|
191
|
+
if (error.stack) err.stack = error.stack
|
|
192
|
+
pending.reject(err)
|
|
193
|
+
} else {
|
|
194
|
+
pending.resolve(result)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
worker.addEventListener('error', (event: ErrorEvent) => {
|
|
198
|
+
const err = new Error(event.message || 'Translation worker crashed')
|
|
199
|
+
for (const p of this.#pending.values()) p.reject(err)
|
|
200
|
+
this.#pending.clear()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
this.#onProgress?.({ status: 'loading', progress: 0 })
|
|
204
|
+
this.#ready = this.#call<boolean>('init', { runtimePath: this.#runtimePath })
|
|
205
|
+
.then(() => undefined)
|
|
206
|
+
.catch((err) => {
|
|
207
|
+
this.#ready = null
|
|
208
|
+
throw err
|
|
209
|
+
})
|
|
210
|
+
return this.#ready
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#fileUrl(entry: PairEntry, name: string): string {
|
|
214
|
+
return joinUrl(this.#modelPath, `${keyOf(entry)}/${name}?rev=${encodeURIComponent(entry.revision)}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #loadEntry(entry: PairEntry): Promise<void> {
|
|
218
|
+
const key = keyOf(entry)
|
|
219
|
+
const existing = this.#loaded.get(key)
|
|
220
|
+
if (existing) return existing
|
|
221
|
+
|
|
222
|
+
const promise = (async () => {
|
|
223
|
+
await this.init()
|
|
224
|
+
const f = entry.files
|
|
225
|
+
const vocabNames = f.vocab ? [f.vocab] : [f.srcvocab!, f.trgvocab!]
|
|
226
|
+
const [model, lex, ...vocabs] = await Promise.all(
|
|
227
|
+
[f.model, f.lex, ...vocabNames].map((name) => fetchFile(this.#fileUrl(entry, name), name, this.#onProgress))
|
|
228
|
+
)
|
|
229
|
+
this.#onProgress?.({ status: 'loading', file: key })
|
|
230
|
+
await this.#call<boolean>('loadModel', { key, modelName: f.model, model, lex, vocabs }, [model, lex, ...vocabs])
|
|
231
|
+
})()
|
|
232
|
+
|
|
233
|
+
this.#loaded.set(key, promise)
|
|
234
|
+
promise.catch(() => this.#loaded.delete(key))
|
|
235
|
+
return promise
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Download (or read from cache) and instantiate every model needed for `from → to`. */
|
|
239
|
+
async load(from: string, to: string): Promise<Route> {
|
|
240
|
+
const route = resolveRoute(from, to)
|
|
241
|
+
if (!route) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`No Firefox Translations model for ${from} → ${to}. See PAIRS in 'runonweb/translate' for the supported pairs.`
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
for (const entry of route) await this.#loadEntry(entry)
|
|
247
|
+
this.#onProgress?.({ status: 'ready', progress: 100 })
|
|
248
|
+
return route
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Translate one or more texts. Models are loaded on demand. */
|
|
252
|
+
async translate(from: string, to: string, texts: string[], options: { html?: boolean } = {}): Promise<string[]> {
|
|
253
|
+
const route = await this.load(from, to)
|
|
254
|
+
return this.#call<string[]>('translate', { route: route.map(keyOf), texts, html: options.html ?? false })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Free a pair from WASM memory. */
|
|
258
|
+
async unload(from: string, to: string): Promise<void> {
|
|
259
|
+
const route = resolveRoute(from, to) ?? []
|
|
260
|
+
for (const entry of route) {
|
|
261
|
+
const key = keyOf(entry)
|
|
262
|
+
if (!this.#loaded.has(key)) continue
|
|
263
|
+
this.#loaded.delete(key)
|
|
264
|
+
await this.#call('freeModel', { key })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
dispose(): void {
|
|
269
|
+
const worker = this.#worker
|
|
270
|
+
this.#worker = null
|
|
271
|
+
this.#ready = null
|
|
272
|
+
this.#loaded.clear()
|
|
273
|
+
const err = new Error('Translator disposed')
|
|
274
|
+
for (const p of this.#pending.values()) p.reject(err)
|
|
275
|
+
this.#pending.clear()
|
|
276
|
+
worker?.terminate()
|
|
277
|
+
if (this.#workerUrl) URL.revokeObjectURL(this.#workerUrl)
|
|
278
|
+
this.#workerUrl = null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { loadPipeline } from '../core/pipeline.ts'
|
|
3
|
+
import { BergamotEngine, DEFAULT_MODEL_PATH, hasPair, resolveRoute, type Route } from './bergamot.ts'
|
|
4
|
+
import { PAIRS, RUNTIME_CDN, RUNTIME_VERSION, type Architecture, type PairEntry } from './registry.ts'
|
|
5
|
+
|
|
6
|
+
export { PAIRS, RUNTIME_CDN, RUNTIME_VERSION, DEFAULT_MODEL_PATH, hasPair, resolveRoute }
|
|
7
|
+
export type { Architecture, PairEntry, Route }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default weights: Mozilla's Firefox Translations models (MPL-2.0), the same ones that power
|
|
11
|
+
* Firefox's built-in translation. Marian NMT students compiled to WASM through Bergamot,
|
|
12
|
+
* 17–44 MB per pair, int8. Pairs without a direct model pivot through English.
|
|
13
|
+
*
|
|
14
|
+
* Pass `model` (a Hugging Face id such as `Xenova/m2m100_418M`) to use a Transformers.js
|
|
15
|
+
* translation pipeline instead: one model for 100 languages, at ~630 MB.
|
|
16
|
+
*/
|
|
17
|
+
export const MULTILINGUAL_MODEL = 'Xenova/m2m100_418M'
|
|
18
|
+
|
|
19
|
+
export type TranslateOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* Transformers.js model id (`Xenova/m2m100_418M`, `Xenova/opus-mt-en-es`…). Omit to use the
|
|
22
|
+
* Firefox Translations models, which are smaller and faster.
|
|
23
|
+
*/
|
|
24
|
+
model?: string
|
|
25
|
+
/**
|
|
26
|
+
* Base URL the Firefox Translations models are served from. Defaults to the runonweb mirror on
|
|
27
|
+
* the Hugging Face Hub. Self-host with `scripts/translate-models.mjs` and pass `/models/translate/`.
|
|
28
|
+
*/
|
|
29
|
+
modelPath?: string
|
|
30
|
+
/** Base URL of the Bergamot WASM runtime. Defaults to jsDelivr; self-host next to the models. */
|
|
31
|
+
runtimePath?: string
|
|
32
|
+
device?: Device
|
|
33
|
+
/** Source language code. Default `en`. */
|
|
34
|
+
from?: string
|
|
35
|
+
/** Target language code. Default `es`. */
|
|
36
|
+
to?: string
|
|
37
|
+
onProgress?: ProgressCallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TranslateRunOptions = {
|
|
41
|
+
from?: string
|
|
42
|
+
to?: string
|
|
43
|
+
/** Treat the input as HTML: tags are preserved and only text nodes are translated. */
|
|
44
|
+
html?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TranslateResult = {
|
|
48
|
+
text: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Pipe = CallableFunction & { dispose?: () => Promise<void> }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Neural machine translation in the browser.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { Translator } from 'runonweb/translate'
|
|
59
|
+
*
|
|
60
|
+
* const translator = new Translator({ from: 'en', to: 'es' })
|
|
61
|
+
* await translator.load()
|
|
62
|
+
* const { text } = await translator.translate('Hello world')
|
|
63
|
+
* // "Hola mundo"
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export class Translator {
|
|
67
|
+
#model: string | null
|
|
68
|
+
#device: Device
|
|
69
|
+
#from: string
|
|
70
|
+
#to: string
|
|
71
|
+
#onProgress?: ProgressCallback
|
|
72
|
+
#engine: BergamotEngine | null = null
|
|
73
|
+
#pipe: Pipe | null = null
|
|
74
|
+
#loading: Promise<void> | null = null
|
|
75
|
+
#resolvedDevice: 'webgpu' | 'wasm' | null = null
|
|
76
|
+
#modelPath?: string
|
|
77
|
+
#runtimePath?: string
|
|
78
|
+
|
|
79
|
+
constructor(options: TranslateOptions = {}) {
|
|
80
|
+
this.#from = options.from ?? 'en'
|
|
81
|
+
this.#to = options.to ?? 'es'
|
|
82
|
+
this.#model = options.model ?? null
|
|
83
|
+
this.#device = options.device ?? 'auto'
|
|
84
|
+
this.#onProgress = options.onProgress
|
|
85
|
+
this.#modelPath = options.modelPath
|
|
86
|
+
this.#runtimePath = options.runtimePath
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when the loaded model takes `src_lang` / `tgt_lang` (M2M100, NLLB). */
|
|
90
|
+
get multilingual(): boolean {
|
|
91
|
+
return this.#model !== null && !this.#model.includes('opus-mt')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Hugging Face id when a Transformers.js model is used, else `firefox-translations`. */
|
|
95
|
+
get model(): string {
|
|
96
|
+
return this.#model ?? 'firefox-translations'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get device(): 'webgpu' | 'wasm' | null {
|
|
100
|
+
return this.#resolvedDevice
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async load(): Promise<void> {
|
|
104
|
+
if (this.#engine || this.#pipe) {
|
|
105
|
+
if (this.#engine) await this.#engine.load(this.#from, this.#to)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (this.#loading) return this.#loading
|
|
109
|
+
|
|
110
|
+
this.#loading = this.#model ? this.#loadTransformers(this.#model) : this.#loadBergamot()
|
|
111
|
+
try {
|
|
112
|
+
await this.#loading
|
|
113
|
+
} finally {
|
|
114
|
+
this.#loading = null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async #loadBergamot(): Promise<void> {
|
|
119
|
+
if (!hasPair(this.#from, this.#to)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`No Firefox Translations model for ${this.#from} → ${this.#to}. See PAIRS in 'runonweb/translate', or pass model: '${MULTILINGUAL_MODEL}'.`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const engine = new BergamotEngine({
|
|
125
|
+
modelPath: this.#modelPath,
|
|
126
|
+
runtimePath: this.#runtimePath,
|
|
127
|
+
onProgress: this.#onProgress,
|
|
128
|
+
})
|
|
129
|
+
await engine.load(this.#from, this.#to)
|
|
130
|
+
this.#engine = engine
|
|
131
|
+
this.#resolvedDevice = 'wasm'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #loadTransformers(model: string): Promise<void> {
|
|
135
|
+
const { pipe, device } = await loadPipeline({
|
|
136
|
+
task: 'translation',
|
|
137
|
+
model,
|
|
138
|
+
device: this.#device,
|
|
139
|
+
// q8 on WASM. Encoder-decoder models fail on WebGPU in ONNX Runtime Web today.
|
|
140
|
+
dtype: 'q8',
|
|
141
|
+
supportedDevices: ['wasm'],
|
|
142
|
+
onProgress: this.#onProgress,
|
|
143
|
+
})
|
|
144
|
+
this.#pipe = pipe
|
|
145
|
+
this.#resolvedDevice = device
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Translate text. Optional `from` / `to` override constructor defaults (ISO codes like `en`,
|
|
150
|
+
* `es`, `zh-Hant`). With the default models any supported pair can be requested per call;
|
|
151
|
+
* new pairs are downloaded on demand.
|
|
152
|
+
*/
|
|
153
|
+
async translate(text: string, options: TranslateRunOptions = {}): Promise<TranslateResult> {
|
|
154
|
+
if (!text.trim()) throw new Error('Text is empty')
|
|
155
|
+
await this.load()
|
|
156
|
+
|
|
157
|
+
const src = options.from ?? this.#from
|
|
158
|
+
const tgt = options.to ?? this.#to
|
|
159
|
+
|
|
160
|
+
this.#onProgress?.({ status: 'translating' })
|
|
161
|
+
|
|
162
|
+
let result: string
|
|
163
|
+
if (this.#engine) {
|
|
164
|
+
const [out] = await this.#engine.translate(src, tgt, [text], { html: options.html })
|
|
165
|
+
result = out ?? ''
|
|
166
|
+
} else {
|
|
167
|
+
result = await this.#translateTransformers(text, src, tgt)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.#onProgress?.({ status: 'done' })
|
|
171
|
+
return { text: result.trim() }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async #translateTransformers(text: string, src: string, tgt: string): Promise<string> {
|
|
175
|
+
const pipe = this.#pipe
|
|
176
|
+
if (!pipe) throw new Error('Translator model failed to load')
|
|
177
|
+
|
|
178
|
+
if (!this.multilingual && (src !== this.#from || tgt !== this.#to)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${this.#model} only translates ${this.#from} → ${this.#to}. Create a new Translator for ${src} → ${tgt}, or use model '${MULTILINGUAL_MODEL}'.`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const raw = (await pipe(text, this.multilingual ? { src_lang: src, tgt_lang: tgt } : {})) as
|
|
185
|
+
| Array<{ translation_text: string }>
|
|
186
|
+
| { translation_text: string }
|
|
187
|
+
const out = Array.isArray(raw) ? raw[0] : raw
|
|
188
|
+
return out?.translation_text ?? ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dispose(): void {
|
|
192
|
+
const engine = this.#engine
|
|
193
|
+
const pipe = this.#pipe
|
|
194
|
+
this.#engine = null
|
|
195
|
+
this.#pipe = null
|
|
196
|
+
this.#resolvedDevice = null
|
|
197
|
+
engine?.dispose()
|
|
198
|
+
void pipe?.dispose?.()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function translate(text: string, options?: TranslateOptions): Promise<TranslateResult> {
|
|
203
|
+
const translator = new Translator(options)
|
|
204
|
+
try {
|
|
205
|
+
return await translator.translate(text, { from: options?.from, to: options?.to })
|
|
206
|
+
} finally {
|
|
207
|
+
translator.dispose()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type { ProgressInfo, Device }
|
|
212
|
+
|
|
213
|
+
/** Common language codes. Firefox Translations pairs use BCP 47 (`zh` is Simplified, `zh-Hant` Traditional). */
|
|
214
|
+
export const LANGS = {
|
|
215
|
+
english: 'en',
|
|
216
|
+
spanish: 'es',
|
|
217
|
+
french: 'fr',
|
|
218
|
+
german: 'de',
|
|
219
|
+
portuguese: 'pt',
|
|
220
|
+
italian: 'it',
|
|
221
|
+
dutch: 'nl',
|
|
222
|
+
russian: 'ru',
|
|
223
|
+
chinese: 'zh',
|
|
224
|
+
traditionalChinese: 'zh-Hant',
|
|
225
|
+
japanese: 'ja',
|
|
226
|
+
korean: 'ko',
|
|
227
|
+
arabic: 'ar',
|
|
228
|
+
hindi: 'hi',
|
|
229
|
+
catalan: 'ca',
|
|
230
|
+
galician: 'gl',
|
|
231
|
+
basque: 'eu',
|
|
232
|
+
} as const
|
|
233
|
+
|
|
234
|
+
/** Sorted list of every direct pair, `<from>-<to>`. */
|
|
235
|
+
export const PAIR_KEYS: readonly string[] = Object.keys(PAIRS).sort()
|
|
236
|
+
|
|
237
|
+
/** Languages that can be translated to or from English. */
|
|
238
|
+
export const LANGUAGES: readonly string[] = [
|
|
239
|
+
...new Set(Object.values(PAIRS).flatMap((p) => [p.from, p.to])),
|
|
240
|
+
].sort()
|