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 */}