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.
155 lines
5.5 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
})();
|