File size: 12,055 Bytes
4602745 674994f 4602745 cd2c99f 4602745 674994f 4602745 d4f1e7c 4602745 5f81dfe 4602745 5f81dfe 4602745 5f81dfe 4602745 cd2c99f 5f81dfe cd2c99f 5f81dfe 4602745 5f81dfe 4602745 5f81dfe 4602745 674994f 4602745 d4f1e7c 4602745 d4f1e7c cd2c99f d4f1e7c cd2c99f d4f1e7c cd2c99f 4602745 674994f 4602745 cd2c99f 4602745 d4f1e7c 4602745 d4f1e7c 4602745 d4f1e7c 4602745 674994f d4f1e7c 4602745 674994f 5f81dfe 674994f 5f81dfe 674994f 4602745 674994f 4602745 d4f1e7c 4602745 d4f1e7c 4602745 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | /**
* webgpu.worker.js
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
* Runs entirely inside a WebWorker context.
* Responsibilities:
* 1. Load a transformers.js Whisper pipeline (WebGPU backend).
* 2. Cache ONNX model weights in the browser via the Cache API for fast
* subsequent cold-starts.
* 3. Implement a "LocalAgreement-2" style streaming stabilisation algorithm
* that withholds partial hypotheses until at least two consecutive
* decoding passes agree on a prefix, preventing jittery live output.
*
* Message protocol (worker β main thread):
* { type: 'load', model: string }
* { type: 'transcribe', audio: Float32Array, id: string }
* { type: 'abort', id: string }
*
* Message protocol (worker β main thread):
* { type: 'ready' }
* { type: 'progress', id, text: string, stable: bool }
* { type: 'result', id, transcript: string, ttft_ms, itl_ms, rtfx }
* { type: 'error', id, message: string }
*/
import { pipeline, env } from '@huggingface/transformers';
// ββ Cache configuration βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Point transformers.js at the Cache Storage API so ONNX weights survive
// page reloads without re-downloading from the CDN.
// useBrowserCache relies on the Cache Storage API which is only available in
// secure contexts (HTTPS or localhost); it is a no-op if the API is absent.
env.useBrowserCache = typeof caches !== 'undefined';
env.allowLocalModels = false;
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** @type {import('@huggingface/transformers').Pipeline | null} */
let asr = null;
let loadedModelId = null;
const abortedIds = new Set();
const LANGUAGE_ISO_MAP = {
english: 'en',
chinese: 'zh',
spanish: 'es',
french: 'fr',
german: 'de',
italian: 'it',
japanese: 'ja',
hindi: 'hi',
};
// ββ LocalAgreement-2 helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Returns the longest common prefix (token-level) shared by *a* and *b*.
* @param {number[]} a
* @param {number[]} b
* @returns {number[]}
*/
function longestCommonPrefix(a, b) {
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a[i] === b[i]) i++;
return a.slice(0, i);
}
/**
* LocalAgreement-2 stabiliser.
*
* Keeps a sliding window of the last two hypotheses. Only the prefix
* agreed upon by *both* is emitted as "stable" text. The remainder is
* surfaced as an unstable suffix so the UI can render it in a dimmed style.
*/
class LocalAgreement2 {
constructor() {
this.prev = /** @type {number[]} */ ([]);
this.stableTokenIds = /** @type {number[]} */ ([]);
}
/**
* Feed in the latest hypothesis token IDs.
* @param {number[]} current Current hypothesis token IDs
* @returns {{ stableTokenIds: number[], unstableTokenIds: number[] }}
*/
update(current) {
const agreed = longestCommonPrefix(this.prev, current);
// Extend the stable set with newly confirmed tokens, but never claim more
// stable tokens than actually exist in the current hypothesis. Capping at
// current.length prevents stale over-commitment when the model produces a
// shorter hypothesis (e.g. at the start of a new audio chunk), which would
// otherwise show confirmed text that is no longer in the active hypothesis.
const newStableLen = Math.min(
Math.max(agreed.length, this.stableTokenIds.length),
current.length,
);
this.stableTokenIds = current.slice(0, newStableLen);
this.prev = current;
const stableTokenIds = this.stableTokenIds;
const unstableTokenIds = current.slice(this.stableTokenIds.length);
return { stableTokenIds, unstableTokenIds };
}
reset() {
this.prev = [];
this.stableTokenIds = [];
}
}
// ββ Model loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function loadModel(modelId) {
if (asr && loadedModelId === modelId) {
self.postMessage({ type: 'ready', modelId });
return;
}
try {
// Dispose of the previous pipeline's ONNX sessions before loading a new
// model. Without this, ONNX Runtime tries to .destroy() GPU buffers that
// were already garbage-collected, throwing "Cannot read properties of
// undefined (reading 'destroy')".
if (asr) {
try { await asr.dispose(); } catch { /* best-effort cleanup */ }
asr = null;
loadedModelId = null;
}
self.postMessage({ type: 'loading', modelId });
// Cohere 2B encoder fp16 has 2 external data shards (~2.5 GB) which
// exceeds browser memory limits. Use q4f16 (1 shard, ~600 MB) instead.
const isCohere = modelId.toLowerCase().includes('cohere');
const gpuDtype = isCohere ? 'q4f16' : 'fp16';
try {
asr = await pipeline('automatic-speech-recognition', modelId, {
device: 'webgpu',
dtype: gpuDtype,
});
} catch {
// WebGPU unavailable (non-secure context, old browser, no GPU) β
// fall back to WASM fp32 so the app still works.
self.postMessage({ type: 'loading', modelId, fallback: 'wasm' });
asr = await pipeline('automatic-speech-recognition', modelId, {
device: 'wasm',
dtype: isCohere ? 'q4' : 'fp32',
});
}
loadedModelId = modelId;
self.postMessage({ type: 'ready', modelId });
} catch (err) {
// Reset state so a subsequent load attempt can retry cleanly.
asr = null;
loadedModelId = null;
self.postMessage({ type: 'error', id: null, message: String(err) });
}
}
// ββ Transcription βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function transcribe(audio, id, language) {
if (!asr) {
self.postMessage({ type: 'error', id, message: 'Model not loaded. Send a load message first.' });
return;
}
const isoLang = language ? (LANGUAGE_ISO_MAP[language.toLowerCase()] || 'en') : undefined;
const la2 = new LocalAgreement2();
const t0 = performance.now();
let firstTokenTime = null;
const tokenTimestamps = [];
try {
// transformers.js callback_function fires after each generated token
// Whisper models support chunked long-form decoding; Cohere uses its own
// encoder and these params cause early truncation.
const isWhisper = (loadedModelId ?? '').toLowerCase().includes('whisper');
// Non-Whisper models (e.g. Cohere ONNX) default to max_length=20 in
// GenerationConfig, truncating transcripts to ~10 new tokens. Force a
// generous limit via BOTH max_new_tokens AND generation_config to ensure
// at least one path overrides the default.
const cohereGenerationOverrides = !isWhisper
? { max_new_tokens: 512, max_length: 1024, generation_config: { max_new_tokens: 512, max_length: 1024 } }
: {};
const result = await asr(audio, {
task: 'transcribe',
...(isoLang ? { language: isoLang } : {}),
// Whisper: chunked long-form decoding with timestamp grounding.
// return_timestamps forces the attention mechanism to anchor to audio
// frames, naturally cutting off silence loops without draconian n-gram
// penalties that truncate valid speech.
...(isWhisper ? {
return_timestamps: true,
chunk_length_s: 30,
stride_length_s: 5,
repetition_penalty: 1.05,
condition_on_prev_tokens: false,
} : {}),
...cohereGenerationOverrides,
callback_function: (beams) => {
if (abortedIds.has(id)) throw new Error('ABORTED');
const now = performance.now();
if (firstTokenTime === null) firstTokenTime = now;
tokenTimestamps.push(now);
// Wrap in try/catch β the WhisperTextStreamer in transformers.js can
// throw "token_ids must be a non-empty array of integers" when
// timestamp tokens are emitted. Swallowing the error here lets the
// pipeline complete; the final bulk result is still accurate.
try {
const beamText = beams[0]?.text ?? '';
/** @type {number[]} */
let tokenIds = [];
if (asr.tokenizer?.encode) {
const encoded = asr.tokenizer.encode(beamText);
if (Array.isArray(encoded)) {
tokenIds = encoded.filter((v) => Number.isFinite(v));
} else if (Array.isArray(encoded?.ids)) {
tokenIds = encoded.ids.filter((v) => Number.isFinite(v));
} else if (Array.isArray(encoded?.input_ids)) {
tokenIds = encoded.input_ids.filter((v) => Number.isFinite(v));
} else if (Array.isArray(encoded?.[0])) {
tokenIds = encoded[0].filter((v) => Number.isFinite(v));
}
}
// Fallback to beam token IDs when encode() is unavailable or returns empty.
if (tokenIds.length === 0) {
tokenIds = (beams[0]?.output_token_ids ?? []).filter((v) => Number.isFinite(v));
}
if (tokenIds.length === 0) return; // nothing to consensus on yet
const { stableTokenIds, unstableTokenIds } = la2.update(tokenIds);
const stable = asr.tokenizer?.decode
? asr.tokenizer.decode(stableTokenIds, { skip_special_tokens: true })
: '';
const unstable = asr.tokenizer?.decode
? asr.tokenizer.decode(unstableTokenIds, { skip_special_tokens: true })
: '';
self.postMessage({ type: 'progress', id, stable, unstable });
} catch (cbErr) {
// Silently skip this streaming tick; the final result is unaffected.
}
},
});
if (abortedIds.has(id)) {
abortedIds.delete(id);
return;
}
const t1 = performance.now();
const totalMs = t1 - t0;
const audioDurationS = audio.length / 16000;
const ttftMs = firstTokenTime !== null ? firstTokenTime - t0 : 0;
let itlMs = 0;
if (tokenTimestamps.length > 1) {
const gaps = tokenTimestamps
.slice(1)
.map((ts, i) => ts - tokenTimestamps[i]);
itlMs = gaps.reduce((s, g) => s + g, 0) / gaps.length;
}
const rtfx = audioDurationS / (totalMs / 1000);
self.postMessage({
type: 'result',
id,
transcript: typeof result.text === 'string' ? result.text : result.text ?? '',
ttft_ms: Math.round(ttftMs * 100) / 100,
itl_ms: Math.round(itlMs * 100) / 100,
rtfx: Math.round(rtfx * 10000) / 10000,
});
} catch (err) {
if (String(err).includes('ABORTED')) {
abortedIds.delete(id);
} else {
self.postMessage({ type: 'error', id, message: String(err) });
}
}
}
// ββ Message handler βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
self.addEventListener('message', async (event) => {
const { type, model, audio, id, language } = event.data;
switch (type) {
case 'load':
await loadModel(model);
break;
case 'transcribe':
await transcribe(audio, id, language);
break;
case 'abort':
abortedIds.add(id);
break;
default:
self.postMessage({ type: 'error', id: null, message: `Unknown message type: ${type}` });
}
});
|