3ce9633Voice dictation notepad that runs entirely in the browserJeremy Magland 1// Transcription worker: runs the selected speech model via transformers.js.
2//
3// Pinned to 3.8.1 deliberately: in 4.2.0 the int8 path fails to create an ONNX
4// session for encoder-decoder ASR models ("TransposeDQWeightsForMatMulNBits
5// Missing required scale"), which breaks both Whisper and Moonshine.
6//
7// One backend per worker, no in-process retry. Once ONNX Runtime has failed to
8// bring up WebGPU its backend registry stays poisoned — a subsequent CPU
9// request in the same worker still resolves to WebGPU and fails identically —
10// so falling back means a fresh worker, which app.js handles.
11//
12// Every request is serialized through one promise chain, so transcription
13// requests that arrive while the model is still downloading queue behind the
14// load rather than failing.
15import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/dist/transformers.min.js';
17env.allowLocalModels = false;
19const SAMPLE_RATE = 16000;
21// Quantizations per backend — see models.js for why they differ.
22const BACKEND = {
23 webgpu: { device: 'webgpu', dtype: { encoder_model: 'fp32', decoder_model_merged: 'q4' } },
24 wasm: { device: 'wasm', dtype: 'q8' },
25};
27let asr = null;
28let loadedDevice = null;
29let chain = Promise.resolve();
31const post = (msg) => self.postMessage(msg);
33self.onmessage = (e) => {
34 const msg = e.data;
35 if (msg.type === 'load') {
36 chain = chain.then(() => load(msg));
37 } else if (msg.type === 'transcribe') {
38 const audio = new Float32Array(msg.audio);
39 chain = chain
40 .then(() => transcribe(msg, audio))
41 .catch((err) => {
42 post({ type: 'error', message: String(err?.message || err) });
43 post({ type: 'result', id: msg.id, utt: msg.utt, final: msg.final, text: '' });
44 });
45 }
46};
48async function hasWebGPU() {
49 if (!navigator.gpu) return false;
50 try {
51 return !!(await navigator.gpu.requestAdapter());
52 } catch {
53 return false;
54 }
55}
57// Sum download progress across files so the bar reflects the whole model.
58function progressReporter() {
59 const files = new Map();
60 return (p) => {
61 if (p.status === 'progress' && p.total) {
62 files.set(p.file, { loaded: p.loaded, total: p.total });
63 let loaded = 0;
64 let total = 0;
65 for (const f of files.values()) {
66 loaded += f.loaded;
67 total += f.total;
68 }
69 post({ type: 'progress', loaded, total });
70 }
71 };
72}
74async function load({ model, device }) {
75 if (asr) {
76 post({ type: 'ready', device: loadedDevice });
77 return;
78 }
80 const target = device === 'cpu' ? 'wasm' : (await hasWebGPU()) ? 'webgpu' : 'wasm';
81 post({ type: 'status', message: `loading model on ${target === 'webgpu' ? 'GPU' : 'CPU'}…` });
83 try {
84 asr = await pipeline('automatic-speech-recognition', model, {
85 ...BACKEND[target],
86 progress_callback: progressReporter(),
87 });
88 // Warm up on 1 s of silence: this is where WebGPU compiles its shaders, and
89 // where a backend that loaded but cannot actually run gives itself away.
90 post({ type: 'status', message: 'warming up…' });
91 await asr(new Float32Array(SAMPLE_RATE));
92 loadedDevice = target;
93 post({ type: 'ready', device: target });
94 } catch (err) {
95 asr = null;
96 console.warn(`voicenote: ${target} backend failed`, err);
97 post({
98 type: 'error',
99 fatal: true,
100 retryOnCpu: target === 'webgpu',
101 message: String(err?.message || err),
102 });
103 }
104}
106async function transcribe(msg, audio) {
107 const out = await asr(audio);
108 post({ type: 'result', id: msg.id, utt: msg.utt, final: msg.final, text: out?.text ?? '' });
109}