/** * Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2). * Loaded at runtime from CDN to avoid bundling issues with onnxruntime-web. * Model is ~634 MB (int8) on first download, cached in IndexedDB after. * * Ported from rnotes-online/src/lib/parakeetOffline.ts */ const CACHE_KEY = 'parakeet-offline-cached'; export interface TranscriptionProgress { status: 'checking' | 'downloading' | 'loading' | 'transcribing' | 'done' | 'error'; progress?: number; file?: string; message?: string; } type ProgressCallback = (progress: TranscriptionProgress) => void; // Singleton model — don't reload on subsequent calls let cachedModel: any = null; let loadingPromise: Promise | null = null; /** * Check if the Parakeet model has been downloaded before. * Best-effort check via localStorage flag; actual cache is in IndexedDB. */ export function isModelCached(): boolean { if (typeof window === 'undefined') return false; return localStorage.getItem(CACHE_KEY) === 'true'; } /** Detect WebGPU availability in the current browser. */ async function detectWebGPU(): Promise { if (typeof navigator === 'undefined' || !(navigator as any).gpu) return false; try { const adapter = await (navigator as any).gpu.requestAdapter(); return !!adapter; } catch { return false; } } /** Get or create the Parakeet model singleton. */ async function getModel(onProgress?: ProgressCallback): Promise { if (cachedModel) return cachedModel; if (loadingPromise) return loadingPromise; loadingPromise = (async () => { onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' }); // Load from CDN at runtime — avoids webpack/Terser issues with onnxruntime-web. const importModule = new Function('url', 'return import(url)'); const { fromHub } = await importModule('https://esm.sh/parakeet.js@1.1.2'); const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm'; const fileProgress: Record = {}; const model = await fromHub('parakeet-tdt-0.6b-v2', { backend, progress: ({ file, loaded, total }: { file: string; loaded: number; total: number }) => { fileProgress[file] = { loaded, total }; let totalBytes = 0; let loadedBytes = 0; for (const fp of Object.values(fileProgress)) { totalBytes += fp.total || 0; loadedBytes += fp.loaded || 0; } if (totalBytes > 0) { const pct = Math.round((loadedBytes / totalBytes) * 100); onProgress?.({ status: 'downloading', progress: pct, file, message: `Downloading Parakeet model... ${pct}%` }); } }, }); localStorage.setItem(CACHE_KEY, 'true'); onProgress?.({ status: 'loading', message: 'Model loaded' }); cachedModel = model; loadingPromise = null; return model; })(); return loadingPromise; } /** Decode an audio Blob to Float32Array at 16 kHz mono. */ async function decodeAudioBlob(blob: Blob): Promise { const arrayBuffer = await blob.arrayBuffer(); const audioCtx = new AudioContext({ sampleRate: 16000 }); try { const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) { return audioBuffer.getChannelData(0); } // Resample via OfflineAudioContext const numSamples = Math.ceil(audioBuffer.duration * 16000); const offlineCtx = new OfflineAudioContext(1, numSamples, 16000); const source = offlineCtx.createBufferSource(); source.buffer = audioBuffer; source.connect(offlineCtx.destination); source.start(); const resampled = await offlineCtx.startRendering(); return resampled.getChannelData(0); } finally { await audioCtx.close(); } } /** * Transcribe an audio Blob offline using Parakeet in the browser. * First call downloads the model (~634 MB). Subsequent calls use cached model. */ export async function transcribeOffline( audioBlob: Blob, onProgress?: ProgressCallback ): Promise { try { const model = await getModel(onProgress); onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' }); const audioData = await decodeAudioBlob(audioBlob); const result = await model.transcribe(audioData, 16000, { returnTimestamps: false, enableProfiling: false, }); const text = result.utterance_text?.trim() || ''; onProgress?.({ status: 'done', message: 'Transcription complete' }); return text; } catch (err) { const message = err instanceof Error ? err.message : 'Transcription failed'; onProgress?.({ status: 'error', message }); throw err; } }