// ============================================================ // 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); }); }); } })();