pediatric-ai-scribe-v3/public/js/browserWhisper.js
ifedan-ed 8ac519a190
Some checks failed
Build TWA APK / build-apk (push) Failing after 1s
Build & Push Docker Image / build (push) Failing after 7s
FIX: Browser Whisper - 100% self-hosted, zero CDN dependencies
FINAL WORKING SOLUTION:

Previous attempts failed because:
- transformers.js v2.17.2 is ES module-only
- Module workers require complex CSP and external imports
- importScripts() doesn't work with ES modules

Solution:
- Use transformers.js v2.6.2 (has worker-compatible UMD build)
- Bundle library + models, serve entirely from our server
- Classic worker with importScripts() - no CSP issues

What's self-hosted:
-  transformers.min.js (760KB) - at /models/transformers.min.js
-  Whisper models (42MB) - at /models/Xenova/whisper-tiny.en/

Worker loads:
1. importScripts('/models/transformers.min.js') - OUR SERVER
2. Loads models from /models/ - OUR SERVER
3. ZERO external network calls
4. Works in any network (firewalled, air-gapped, etc.)

This is the production-ready, truly offline solution.
2026-03-31 23:12:21 +00:00

155 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;
_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;
_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);
});
});
}
})();