diff --git a/src/app/voice/page.tsx b/src/app/voice/page.tsx index bed4aae..bc46740 100644 --- a/src/app/voice/page.tsx +++ b/src/app/voice/page.tsx @@ -6,6 +6,12 @@ import { AppSwitcher } from '@/components/AppSwitcher'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; import { UserMenu } from '@/components/UserMenu'; import { authFetch } from '@/lib/authFetch'; +import { isModelCached } from '@/lib/parakeetOffline'; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} // --- Types --- @@ -100,6 +106,105 @@ export default function VoicePage() { const transcriptRef = useRef(null); const editRef = useRef(null); + // PWA install + offline model + const [installPrompt, setInstallPrompt] = useState(null); + const [isInstalled, setIsInstalled] = useState(false); + const [modelCached, setModelCached] = useState(false); + const [modelDownloading, setModelDownloading] = useState(false); + const [modelProgress, setModelProgress] = useState(null); + + useEffect(() => { + // Check install state + const standalone = window.matchMedia('(display-mode: standalone)').matches + || (navigator as unknown as { standalone?: boolean }).standalone === true; + setIsInstalled(standalone); + + // Check model cache + setModelCached(isModelCached()); + + // Capture install prompt + const handler = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e as BeforeInstallPromptEvent); + }; + window.addEventListener('beforeinstallprompt', handler); + window.addEventListener('appinstalled', () => { + setIsInstalled(true); + setInstallPrompt(null); + }); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const handleInstallApp = useCallback(async () => { + if (installPrompt) { + installPrompt.prompt(); + const { outcome } = await installPrompt.userChoice; + if (outcome === 'accepted') { + setIsInstalled(true); + // Start model download after install + if (!modelCached) downloadModel(); + } + setInstallPrompt(null); + } + }, [installPrompt, modelCached]); + + const downloadModel = useCallback(async () => { + if (modelCached || modelDownloading) return; + setModelDownloading(true); + try { + const { transcribeOffline } = await import('@/lib/parakeetOffline'); + // Create a tiny silent audio blob to trigger model download + warm-up + const silentCtx = new AudioContext({ sampleRate: 16000 }); + const buffer = silentCtx.createBuffer(1, 16000, 16000); // 1 second of silence + const wavBlob = await new Promise((resolve) => { + const offlineCtx = new OfflineAudioContext(1, 16000, 16000); + const src = offlineCtx.createBufferSource(); + src.buffer = buffer; + src.connect(offlineCtx.destination); + src.start(); + offlineCtx.startRendering().then((rendered) => { + const float32 = rendered.getChannelData(0); + // Encode as WAV + const wavHeader = new ArrayBuffer(44); + const view = new DataView(wavHeader); + const pcmLen = float32.length * 2; + // RIFF header + view.setUint32(0, 0x52494646, false); // "RIFF" + view.setUint32(4, 36 + pcmLen, true); + view.setUint32(8, 0x57415645, false); // "WAVE" + // fmt chunk + view.setUint32(12, 0x666d7420, false); // "fmt " + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // PCM + view.setUint16(22, 1, true); // mono + view.setUint32(24, 16000, true); // sample rate + view.setUint32(28, 32000, true); // byte rate + view.setUint16(32, 2, true); // block align + view.setUint16(34, 16, true); // bits per sample + // data chunk + view.setUint32(36, 0x64617461, false); // "data" + view.setUint32(40, pcmLen, true); + const pcm = new Int16Array(float32.length); + for (let i = 0; i < float32.length; i++) { + pcm[i] = Math.max(-32768, Math.min(32767, float32[i] * 32767)); + } + resolve(new Blob([wavHeader, pcm.buffer], { type: 'audio/wav' })); + }); + }); + await silentCtx.close(); + + await transcribeOffline(wavBlob, (p) => setModelProgress(p)); + setModelCached(true); + setModelProgress(null); + } catch (err) { + console.warn('Model download failed:', err); + setModelProgress({ status: 'error', message: 'Download failed - will retry on next use' }); + setTimeout(() => setModelProgress(null), 3000); + } finally { + setModelDownloading(false); + } + }, [modelCached, modelDownloading]); + // Load notebooks useEffect(() => { authFetch('/api/notebooks') @@ -541,11 +646,73 @@ export default function VoicePage() { Local )} + {/* Install app button */} + {!isInstalled && installPrompt && ( + + )} + {/* Download offline model button */} + {isInstalled && !modelCached && !modelDownloading && ( + + )} + {/* Offline ready badge */} + {modelCached && ( + + + + + Offline + + )} + {/* Model download progress bar */} + {modelDownloading && modelProgress && ( +
+
+
+ + {modelProgress.message || 'Downloading offline model...'} + + {modelProgress.status === 'downloading' && modelProgress.progress !== undefined && ( + {modelProgress.progress}% + )} +
+ {modelProgress.status === 'downloading' && ( +
+
+
+ )} + {modelProgress.status === 'loading' && ( +
+
+
+ )} +
+
+ )} + {/* Main content */}
@@ -584,6 +751,31 @@ export default function VoicePage() { {state === 'processing' && (offlineProgress?.message || 'Processing...')} {state === 'done' && 'Recording complete'}

+ + {/* Install + offline CTA when idle and not installed */} + {state === 'idle' && !isInstalled && installPrompt && ( + + )} + {/* Download model CTA when installed but model not cached */} + {state === 'idle' && isInstalled && !modelCached && !modelDownloading && ( + + )}
{/* Offline model progress bar */}