pediatric-ai-scribe-v3/public/js/browserWhisper.js
ifedan-ed 0cb1cac7ae Fix Browser Whisper: Use ES module worker with CDN library
Issue: transformers.js is an ES module package and cannot be loaded
with importScripts() in classic workers.

Solution:
- Changed to module worker (type: 'module')
- Import transformers.js from CDN as ES module
- Models (42MB) still served from local server at /models/

Trade-off:
- Library (900KB): Loads from cdn.jsdelivr.net once, cached
- Models (42MB): Self-hosted, served from /models/ (no CDN)

This is necessary because:
1. @xenova/transformers is ES module-only (package.json: "type": "module")
2. ES modules cannot use importScripts()
3. Module workers require HTTPS for imports
4. CDN is HTTPS and cacheable

If CDN is blocked:
- Use Web Speech API (with privacy warnings)
- OR use Server Transcription (Vertex AI/AWS)

Models remain self-hosted as they're 40MB+ and contain the AI.
2026-03-31 22:55:28 +00:00

156 lines
5.5 KiB
JavaScript

// ============================================================
// BROWSER WHISPER — client-side transcription, zero server calls
// Uses @xenova/transformers running Whisper in WebAssembly.
// Audio is processed entirely in-browser, never transmitted.
//
// Models (cached in IndexedDB after first download):
// Xenova/whisper-tiny.en — ~39MB — fastest, ~2-3s/clip
// Xenova/whisper-base.en — ~74MB — balanced, ~3-5s/clip
// Xenova/whisper-small.en — ~244MB — best quality, ~6-10s/clip
// ============================================================
(function() {
var STORAGE_ENABLED = 'ped_browser_whisper';
var STORAGE_MODEL = 'ped_whisper_model';
var DEFAULT_MODEL = 'Xenova/whisper-tiny.en';
var _worker = null;
var _ready = false;
var _loading = false;
var _modelLoaded = null;
var _pending = null; // { resolve, reject }
window.BrowserWhisper = {
isSupported: function() {
return typeof Worker !== 'undefined' &&
typeof AudioContext !== 'undefined' &&
typeof WebAssembly !== 'undefined';
},
isEnabled: function() {
try { return localStorage.getItem(STORAGE_ENABLED) === '1'; } catch(e) { return false; }
},
setEnabled: function(val) {
try { localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0'); } catch(e) {}
},
getModel: function() {
try { return localStorage.getItem(STORAGE_MODEL) || DEFAULT_MODEL; } catch(e) { return DEFAULT_MODEL; }
},
setModel: function(m) {
try { localStorage.setItem(STORAGE_MODEL, m); } catch(e) {}
// Force reload next time
_ready = false;
_modelLoaded = null;
},
// Pre-warm: load model in background before first recording
preload: function(onProgress) {
if (!this.isSupported() || !this.isEnabled()) return;
_initWorker(this.getModel(), onProgress || function() {});
},
// Transcribe a Blob (WebM, WAV, etc.)
transcribe: function(blob) {
var self = this;
if (!this.isSupported()) return Promise.reject(new Error('WebAssembly not supported'));
if (!this.isEnabled()) return Promise.reject(new Error('Browser Whisper not enabled'));
return _blobToFloat32(blob).then(function(float32) {
return new Promise(function(resolve, reject) {
var model = self.getModel();
// If worker is ready with same model, send immediately
if (_ready && _modelLoaded === model) {
_pending = { resolve: resolve, reject: reject };
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
return;
}
// Need to (re)load model first
_pending = { resolve: resolve, reject: reject };
_initWorker(model, function() {}, function() {
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
});
});
});
}
};
// ── Internal ──────────────────────────────────────────────
function _initWorker(model, onProgress, onReady) {
if (_loading && _modelLoaded === model) return; // already loading same model
if (_worker) { _worker.terminate(); _worker = null; }
_ready = false;
_loading = true;
_modelLoaded = model;
// Use module worker to support ES modules
_worker = new Worker('/js/whisperWorker.js', { type: 'module' });
_worker.addEventListener('message', function(e) {
var d = e.data;
if (d.type === 'loading') {
if (onProgress) onProgress('Downloading Whisper model (' + d.model.split('/').pop() + ')...', 0);
}
if (d.type === 'progress') {
if (onProgress) onProgress(d.file.split('/').pop() || 'model', d.progress);
}
if (d.type === 'ready') {
_ready = true;
_loading = false;
if (onProgress) onProgress('', 100);
if (onReady) onReady();
}
if (d.type === 'result') {
if (_pending) { _pending.resolve(d.text); _pending = null; }
}
if (d.type === 'error') {
_loading = false;
_ready = false;
console.error('[BrowserWhisper] Worker error:', d.message);
// Show user-friendly message
if (typeof showToast === 'function') {
showToast('Browser Whisper blocked by network/firewall. Using server transcription.', 'warning');
}
if (_pending) { _pending.reject(new Error(d.message)); _pending = null; }
if (onProgress) onProgress('', 0);
}
});
_worker.addEventListener('error', function(err) {
_loading = false;
_ready = false;
console.error('[BrowserWhisper] Worker crashed:', err);
if (typeof showToast === 'function') {
showToast('Browser Whisper unavailable. Using server transcription.', 'warning');
}
if (_pending) { _pending.reject(err); _pending = null; }
if (onProgress) onProgress('', 0);
});
_worker.postMessage({ type: 'load', model: model });
}
// Convert audio Blob → Float32Array at 16kHz mono (what Whisper expects)
function _blobToFloat32(blob) {
return blob.arrayBuffer().then(function(buf) {
var ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
return ctx.decodeAudioData(buf).then(function(audioBuffer) {
ctx.close();
return audioBuffer.getChannelData(0); // mono
}).catch(function(err) {
ctx.close();
throw new Error('Audio decode failed: ' + err.message);
});
});
}
})();