// Transcription worker: runs the selected speech model via transformers.js. // // Pinned to 3.8.1 deliberately: in 4.2.0 the int8 path fails to create an ONNX // session for encoder-decoder ASR models ("TransposeDQWeightsForMatMulNBits // Missing required scale"), which breaks both Whisper and Moonshine. // // One backend per worker, no in-process retry. Once ONNX Runtime has failed to // bring up WebGPU its backend registry stays poisoned — a subsequent CPU // request in the same worker still resolves to WebGPU and fails identically — // so falling back means a fresh worker, which app.js handles. // // Every request is serialized through one promise chain, so transcription // requests that arrive while the model is still downloading queue behind the // load rather than failing. import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/dist/transformers.min.js'; env.allowLocalModels = false; const SAMPLE_RATE = 16000; // Quantizations per backend — see models.js for why they differ. const BACKEND = { webgpu: { device: 'webgpu', dtype: { encoder_model: 'fp32', decoder_model_merged: 'q4' } }, wasm: { device: 'wasm', dtype: 'q8' }, }; let asr = null; let loadedDevice = null; let chain = Promise.resolve(); const post = (msg) => self.postMessage(msg); self.onmessage = (e) => { const msg = e.data; if (msg.type === 'load') { chain = chain.then(() => load(msg)); } else if (msg.type === 'transcribe') { const audio = new Float32Array(msg.audio); chain = chain .then(() => transcribe(msg, audio)) .catch((err) => { post({ type: 'error', message: String(err?.message || err) }); post({ type: 'result', id: msg.id, utt: msg.utt, final: msg.final, text: '' }); }); } }; async function hasWebGPU() { if (!navigator.gpu) return false; try { return !!(await navigator.gpu.requestAdapter()); } catch { return false; } } // Sum download progress across files so the bar reflects the whole model. function progressReporter() { const files = new Map(); return (p) => { if (p.status === 'progress' && p.total) { files.set(p.file, { loaded: p.loaded, total: p.total }); let loaded = 0; let total = 0; for (const f of files.values()) { loaded += f.loaded; total += f.total; } post({ type: 'progress', loaded, total }); } }; } async function load({ model, device }) { if (asr) { post({ type: 'ready', device: loadedDevice }); return; } const target = device === 'cpu' ? 'wasm' : (await hasWebGPU()) ? 'webgpu' : 'wasm'; post({ type: 'status', message: `loading model on ${target === 'webgpu' ? 'GPU' : 'CPU'}…` }); try { asr = await pipeline('automatic-speech-recognition', model, { ...BACKEND[target], progress_callback: progressReporter(), }); // Warm up on 1 s of silence: this is where WebGPU compiles its shaders, and // where a backend that loaded but cannot actually run gives itself away. post({ type: 'status', message: 'warming up…' }); await asr(new Float32Array(SAMPLE_RATE)); loadedDevice = target; post({ type: 'ready', device: target }); } catch (err) { asr = null; console.warn(`voicenote: ${target} backend failed`, err); post({ type: 'error', fatal: true, retryOnCpu: target === 'webgpu', message: String(err?.message || err), }); } } async function transcribe(msg, audio) { const out = await asr(audio); post({ type: 'result', id: msg.id, utt: msg.utt, final: msg.final, text: out?.text ?? '' }); }