pediatric-ai-scribe-v3/public/js/browserWhisper.js
Daniel Onyejesi f126cf9fd7 Add browser-side Whisper transcription (local, zero network, HIPAA-safe)
- whisperWorker.js: Web Worker running @xenova/transformers Whisper in WASM
- browserWhisper.js: main-thread manager — audio→Float32 conversion, worker lifecycle
- transcribeAudio() checks BrowserWhisper.isEnabled() first, falls back to server
- Settings UI: enable/disable, model picker (tiny/base/small), pre-download button
- CSP: add wasm-unsafe-eval, cdn.jsdelivr.net, HuggingFace CDN domains
- Default: whisper-tiny.en (~39MB, ~2-3s per clip)
2026-03-31 07:30:17 -04:00

143 lines
4.9 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;
_worker = new Worker('/js/whisperWorker.js');
_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;
if (_pending) { _pending.reject(new Error(d.message)); _pending = null; }
}
});
_worker.addEventListener('error', function(err) {
_loading = false;
_ready = false;
if (_pending) { _pending.reject(err); _pending = null; }
});
_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);
});
});
}
})();