whisper-web-transcriber
Version:
Real-time audio transcription in the browser using OpenAI's Whisper model via WebAssembly
451 lines (442 loc) • 19.4 kB
JavaScript
class WhisperTranscriber {
constructor(config = {}) {
this.instance = null;
this.mediaRecorder = null;
this.audioContext = null;
this.isRecording = false;
this.audio = null;
this.audio0 = null;
this.Module = null;
this.modelLoaded = false;
this.initPromise = null;
this.config = {
modelUrl: config.modelUrl || WhisperTranscriber.MODEL_URLS[config.modelSize || 'base-en-q5_1'],
modelSize: config.modelSize || 'base-en-q5_1',
sampleRate: config.sampleRate || 16000,
audioIntervalMs: config.audioIntervalMs || 5000,
onTranscription: config.onTranscription || (() => { }),
onProgress: config.onProgress || (() => { }),
onStatus: config.onStatus || (() => { }),
debug: config.debug || false,
};
// Auto-register COI service worker if needed
this.registerServiceWorkerIfNeeded();
}
log(message) {
if (this.config.debug) {
console.log('[WhisperTranscriber]', message);
}
}
async registerServiceWorkerIfNeeded() {
// Check if we need COI and service worker is available
if (!window.crossOriginIsolated) {
// For CDN usage, we cannot auto-register service workers due to same-origin policy
// Instead, provide instructions or helper method
if (window.COI_SERVICEWORKER_CODE) {
console.warn('[WhisperTranscriber] SharedArrayBuffer is not available. ' +
'To enable it, you need to serve your site with COOP/COEP headers or use a service worker.\n' +
'You can get the service worker code by calling: transcriber.getServiceWorkerCode()');
}
}
}
/**
* Returns the COI service worker code that users need to save and serve from their domain
*/
getServiceWorkerCode() {
if (window.COI_SERVICEWORKER_CODE) {
return window.COI_SERVICEWORKER_CODE;
}
return null;
}
/**
* Helper to generate instructions for setting up Cross-Origin Isolation
*/
getCrossOriginIsolationInstructions() {
const swCode = this.getServiceWorkerCode();
if (!window.crossOriginIsolated) {
return `
Cross-Origin Isolation Setup Required
=====================================
WhisperTranscriber requires SharedArrayBuffer, which needs Cross-Origin Isolation.
Option 1: Server Headers (Recommended)
--------------------------------------
Configure your server to send these headers:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Option 2: Service Worker
------------------------
1. Save the following code as 'coi-serviceworker.js' in your website root:
${swCode ? '--- START SERVICE WORKER CODE ---\n' + swCode + '\n--- END SERVICE WORKER CODE ---' : '[Service worker code not available]'}
2. Register the service worker by adding this to your HTML:
<script src="/coi-serviceworker.js"></script>
3. Reload the page after registration.
Current Status:
- crossOriginIsolated: ${window.crossOriginIsolated}
- SharedArrayBuffer available: ${typeof SharedArrayBuffer !== 'undefined'}
`.trim();
}
return 'Cross-Origin Isolation is already enabled! No action needed.';
}
getScriptBasePath() {
// Check if we're running from node_modules
const scriptTags = document.getElementsByTagName('script');
for (let i = 0; i < scriptTags.length; i++) {
const src = scriptTags[i].src;
if (src.includes('whisper-web-transcriber')) {
const basePath = src.substring(0, src.lastIndexOf('/') + 1);
return basePath;
}
}
// Try to detect if we're in development mode
if (window.location.hostname === 'localhost') {
if (window.location.pathname.includes('/demo/')) {
return '../dist/';
}
// Check if loaded from node_modules
return '/node_modules/whisper-web-transcriber/dist/';
}
// Default to unpkg CDN for production
return 'https://unpkg.com/whisper-web-transcriber/dist/';
}
async createWorkerFromURL(url) {
// Fetch the worker script
const response = await fetch(url);
const workerCode = await response.text();
// Create a blob URL for the worker
const blob = new Blob([workerCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
return new Worker(blobUrl);
}
async loadWasmModule() {
// Check if we have inlined worker code
if (window.LIBSTREAM_WORKER_CODE) {
// Use inlined worker
this.log('Using inlined worker code');
const workerBlob = new Blob([window.LIBSTREAM_WORKER_CODE], { type: 'application/javascript' });
const workerBlobUrl = URL.createObjectURL(workerBlob);
window.__whisperWorkerBlobUrl = workerBlobUrl;
this.log('Worker blob URL created from inlined code');
}
else {
// Fallback to fetching worker
const basePath = this.getScriptBasePath();
const workerUrl = basePath + 'libstream.worker.js';
try {
// Pre-fetch and convert worker to blob URL
const response = await fetch(workerUrl);
const workerCode = await response.text();
const blob = new Blob([workerCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// Store the blob URL for later use
window.__whisperWorkerBlobUrl = blobUrl;
this.log('Worker script loaded and blob URL created');
}
catch (error) {
this.log('Failed to pre-fetch worker: ' + error);
// Continue anyway, it might work with direct loading
}
}
return new Promise((resolve, reject) => {
// Configure Module before the script loads
window.Module = {
locateFile: (path) => {
// If it's the worker and we have a blob URL, use it
if (path === 'libstream.worker.js' && window.__whisperWorkerBlobUrl) {
return window.__whisperWorkerBlobUrl;
}
return this.getScriptBasePath() + path;
},
onRuntimeInitialized: () => {
this.log('WASM runtime initialized');
// The runtime is initialized, we can resolve immediately
// The Module will set up the whisper functions
setTimeout(() => {
const module = window.Module;
if (module) {
this.Module = module;
// Set up the whisper functions if they don't exist
if (!module.init) {
module.init = module.cwrap('init', 'number', ['string']);
}
if (!module.set_audio) {
module.set_audio = module.cwrap('set_audio', '', ['number', 'array']);
}
if (!module.get_transcribed) {
module.get_transcribed = module.cwrap('get_transcribed', 'string', []);
}
if (!module.set_status) {
module.set_status = module.cwrap('set_status', '', ['string']);
}
this.log('WASM module loaded and functions initialized');
resolve();
}
else {
reject(new Error('Module not available after runtime initialized'));
}
}, 100);
}
};
// Load the WASM module
if (window.LIBSTREAM_CODE) {
// Use inlined libstream code
this.log('Using inlined libstream code');
const scriptBlob = new Blob([window.LIBSTREAM_CODE], { type: 'application/javascript' });
const scriptUrl = URL.createObjectURL(scriptBlob);
const script = document.createElement('script');
script.src = scriptUrl;
script.onerror = () => reject(new Error('Failed to load WASM module'));
document.head.appendChild(script);
}
else {
// Load the WASM module dynamically
const script = document.createElement('script');
script.src = this.getScriptBasePath() + 'libstream.js';
script.onerror = () => reject(new Error('Failed to load WASM module'));
document.head.appendChild(script);
}
});
}
async loadHelpers() {
if (window.HELPERS_CODE) {
// Use inlined helpers code
this.log('Using inlined helpers code');
const scriptBlob = new Blob([window.HELPERS_CODE], { type: 'application/javascript' });
const scriptUrl = URL.createObjectURL(scriptBlob);
const script = document.createElement('script');
script.src = scriptUrl;
return new Promise((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load helpers'));
document.head.appendChild(script);
});
}
else {
// Load helpers.js normally
const script = document.createElement('script');
script.src = this.getScriptBasePath() + 'helpers.js';
return new Promise((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load helpers'));
document.head.appendChild(script);
});
}
}
async loadCOIServiceWorker() {
// Check if SharedArrayBuffer is already available
if (typeof SharedArrayBuffer !== 'undefined') {
this.log('SharedArrayBuffer already available');
return;
}
// Try to load coi-serviceworker.js
const basePath = this.getScriptBasePath();
const script = document.createElement('script');
script.src = basePath + 'coi-serviceworker.js';
return new Promise((resolve) => {
script.onload = () => {
this.log('COI service worker loaded');
resolve();
};
script.onerror = () => {
this.log('Failed to load COI service worker - SharedArrayBuffer may not be available');
resolve(); // Continue anyway
};
document.head.appendChild(script);
});
}
async initialize() {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = (async () => {
try {
// Try to load COI service worker first for SharedArrayBuffer support
await this.loadCOIServiceWorker();
// Set up global variables required by helpers.js
window.dbVersion = 1;
window.dbName = 'whisper.transcriber.models';
// Don't override indexedDB, it's already a global property
// Load helpers first
await this.loadHelpers();
this.log('Helpers loaded');
// Then load WASM module
await this.loadWasmModule();
this.log('WASM module initialized');
this.config.onStatus('Ready to load model');
}
catch (error) {
this.log('Failed to initialize: ' + error);
throw error;
}
})();
return this.initPromise;
}
async loadModel() {
if (this.modelLoaded) {
this.log('Model already loaded');
return;
}
await this.initialize();
return new Promise((resolve, reject) => {
const url = this.config.modelUrl;
const size_mb = WhisperTranscriber.MODEL_SIZES[this.config.modelSize];
this.config.onStatus('Loading model...');
const storeFS = (fname, buf) => {
try {
this.Module.FS_unlink(fname);
}
catch (e) {
// File doesn't exist, ignore
}
this.Module.FS_createDataFile("/", fname, buf, true, true);
this.log(`Model stored: ${fname}, size: ${buf.length}`);
this.modelLoaded = true;
this.config.onStatus('Model loaded successfully');
resolve();
};
const cbProgress = (progress) => {
this.config.onProgress(Math.round(progress * 100));
};
const cbCancel = () => {
this.config.onStatus('Model loading cancelled');
reject(new Error('Model loading cancelled'));
};
const cbPrint = (msg) => {
this.log(msg);
};
// Use the global loadRemote function from helpers.js
window.loadRemote(url, 'whisper.bin', size_mb, cbProgress, storeFS, cbCancel, cbPrint);
});
}
async startRecording() {
if (!this.modelLoaded) {
throw new Error('Model not loaded. Call loadModel() first.');
}
if (this.isRecording) {
this.log('Already recording');
return;
}
// Initialize whisper instance
if (!this.instance) {
// Check if init function exists, otherwise use cwrap
const init = this.Module.init || this.Module.cwrap('init', 'number', ['string']);
this.instance = init('whisper.bin');
if (!this.instance) {
throw new Error('Failed to initialize Whisper');
}
this.log('Whisper instance initialized');
}
// Create audio context
this.audioContext = new AudioContext({
sampleRate: this.config.sampleRate,
// @ts-ignore - These properties might not be in the type definition
channelCount: 1,
echoCancellation: false,
autoGainControl: true,
noiseSuppression: true,
});
const set_status = this.Module.set_status || this.Module.cwrap('set_status', '', ['string']);
set_status("");
this.isRecording = true;
this.config.onStatus('Recording...');
const chunks = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (e) => {
chunks.push(e.data);
const blob = new Blob(chunks, { type: 'audio/ogg; codecs=opus' });
const reader = new FileReader();
reader.onload = (event) => {
const buf = new Uint8Array(event.target.result);
if (!this.audioContext)
return;
this.audioContext.decodeAudioData(buf.buffer, (audioBuffer) => {
const offlineContext = new OfflineAudioContext(audioBuffer.numberOfChannels, audioBuffer.length, audioBuffer.sampleRate);
const source = offlineContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineContext.destination);
source.start(0);
offlineContext.startRendering().then((renderedBuffer) => {
this.audio = renderedBuffer.getChannelData(0);
const audioAll = new Float32Array(this.audio0 == null ? this.audio.length : this.audio0.length + this.audio.length);
if (this.audio0 != null) {
audioAll.set(this.audio0, 0);
}
audioAll.set(this.audio, this.audio0 == null ? 0 : this.audio0.length);
if (this.instance) {
const set_audio = this.Module.set_audio || this.Module.cwrap('set_audio', '', ['number', 'array']);
set_audio(this.instance, audioAll);
}
});
});
};
reader.readAsArrayBuffer(blob);
};
this.mediaRecorder.onstop = () => {
if (this.isRecording) {
setTimeout(() => this.startRecording(), 0);
}
};
this.mediaRecorder.start(this.config.audioIntervalMs);
// Start transcription polling
this.startTranscriptionPolling();
}
catch (error) {
this.isRecording = false;
this.config.onStatus('Error: ' + error.message);
throw error;
}
}
startTranscriptionPolling() {
const interval = setInterval(() => {
if (!this.isRecording) {
clearInterval(interval);
return;
}
const get_transcribed = this.Module.get_transcribed || this.Module.cwrap('get_transcribed', 'string', []);
const transcribed = get_transcribed();
if (transcribed != null && transcribed.length > 1) {
this.config.onTranscription(transcribed);
}
}, 100);
}
stopRecording() {
if (!this.isRecording) {
this.log('Not recording');
return;
}
const set_status = this.Module.set_status || this.Module.cwrap('set_status', '', ['string']);
set_status("paused");
this.isRecording = false;
this.audio0 = null;
this.audio = null;
if (this.mediaRecorder) {
this.mediaRecorder.stop();
this.mediaRecorder = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.config.onStatus('Stopped');
}
destroy() {
this.stopRecording();
this.instance = null;
this.Module = null;
this.modelLoaded = false;
}
}
WhisperTranscriber.MODEL_URLS = {
'tiny.en': 'https://whisper.ggerganov.com/ggml-model-whisper-tiny.en.bin',
'base.en': 'https://whisper.ggerganov.com/ggml-model-whisper-base.en.bin',
'tiny-en-q5_1': 'https://whisper.ggerganov.com/ggml-model-whisper-tiny.en-q5_1.bin',
'base-en-q5_1': 'https://whisper.ggerganov.com/ggml-model-whisper-base.en-q5_1.bin',
};
WhisperTranscriber.MODEL_SIZES = {
'tiny.en': 75,
'base.en': 142,
'tiny-en-q5_1': 31,
'base-en-q5_1': 57,
};
export { WhisperTranscriber };