From 4c939bc45e3dc0388817c29dcc515ba82a0b60ea Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Feb 2026 19:35:15 -0800 Subject: [PATCH 1/5] feat: wire rnotes to pull secrets from Infisical at startup Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 ++ docker-compose.yml | 11 +++---- entrypoint.sh | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 3fdd931..11b53fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY rnotes-online/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 428f244..a6adfca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,12 @@ services: container_name: rnotes-online restart: unless-stopped environment: + - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} + - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} + - INFISICAL_PROJECT_SLUG=rnotes + - INFISICAL_ENV=prod + - INFISICAL_URL=http://infisical:8080 - DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-postgres:5432/rnotes - - NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online} - - RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000} - - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com} - - RSPACE_INTERNAL_KEY=${RSPACE_INTERNAL_KEY} - - VOICE_API_URL=${VOICE_API_URL:-http://voice-command-api:8000} - - NEXT_PUBLIC_VOICE_WS_URL=${NEXT_PUBLIC_VOICE_WS_URL:-wss://voice.jeffemmett.com} volumes: - uploads_data:/app/uploads labels: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..bfbfb35 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# Infisical secret injection entrypoint +# Fetches secrets from Infisical API and injects them as env vars before starting the app. +# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET +# Optional: INFISICAL_PROJECT_SLUG (default: rnotes), INFISICAL_ENV (default: prod), +# INFISICAL_URL (default: http://infisical:8080) + +set -e + +INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}" +INFISICAL_ENV="${INFISICAL_ENV:-prod}" +INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rnotes}" + +if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then + echo "[infisical] No credentials set, starting without secret injection" + exec "$@" +fi + +echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..." + +# Use Node.js (already in the image) for reliable JSON parsing and HTTP calls +EXPORTS=$(node -e " +const http = require('http'); +const https = require('https'); +const url = new URL(process.env.INFISICAL_URL); +const client = url.protocol === 'https:' ? https : http; + +const post = (path, body) => new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': data.length } + }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); }); + req.on('error', reject); + req.end(data); +}); + +const get = (path, token) => new Promise((resolve, reject) => { + const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'GET', + headers: { 'Authorization': 'Bearer ' + token } + }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); }); + req.on('error', reject); + req.end(); +}); + +(async () => { + try { + const auth = await post('/api/v1/auth/universal-auth/login', { + clientId: process.env.INFISICAL_CLIENT_ID, + clientSecret: process.env.INFISICAL_CLIENT_SECRET + }); + if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); } + + const slug = process.env.INFISICAL_PROJECT_SLUG; + const env = process.env.INFISICAL_ENV; + const secrets = await get('/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', auth.accessToken); + + if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); } + + // Output as shell-safe export statements + for (const s of secrets.secrets) { + // Single-quote the value to prevent shell expansion, escape existing single quotes + const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" ); + console.log('export ' + s.secretKey + \"='\" + escaped + \"'\"); + } + } catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); } +})(); +" 2>&1) || { + echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars" + exec "$@" +} + +# Check if we got export statements or error messages +if echo "$EXPORTS" | grep -q "^export "; then + COUNT=$(echo "$EXPORTS" | grep -c "^export ") + eval "$EXPORTS" + echo "[infisical] Injected ${COUNT} secrets" +else + echo "[infisical] WARNING: $EXPORTS" + echo "[infisical] Starting with existing env vars" +fi + +exec "$@" From 17f2d49f128b66ef1b225ee8ed4c7aa6a37ba62d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 15:32:37 -0800 Subject: [PATCH 2/5] feat: add article unlock feature for paywalled content Multi-strategy approach to find readable versions of paywalled articles: 1. Wayback Machine (check existing + Save Page Now) 2. Google Web Cache 3. archive.ph (read-only check for existing snapshots) Adds archiveUrl field to Note model, /api/articles/unlock endpoint, unlock button on note detail page, and browser extension integration. Co-Authored-By: Claude Opus 4.6 --- browser-extension/background.js | 53 ++++++ browser-extension/popup.html | 18 +++ browser-extension/popup.js | 44 +++++ prisma/schema.prisma | 1 + src/app/api/articles/unlock/route.ts | 61 +++++++ src/app/api/notes/[id]/route.ts | 3 +- src/app/api/notes/route.ts | 3 +- src/app/notes/[id]/page.tsx | 92 +++++++++-- src/components/NoteCard.tsx | 8 +- src/lib/article-unlock.ts | 232 +++++++++++++++++++++++++++ 10 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 src/app/api/articles/unlock/route.ts create mode 100644 src/lib/article-unlock.ts diff --git a/browser-extension/background.js b/browser-extension/background.js index 31120cb..fd21c04 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -26,6 +26,12 @@ chrome.runtime.onInstalled.addListener(() => { title: 'Clip selection to rNotes', contexts: ['selection'], }); + + chrome.contextMenus.create({ + id: 'unlock-article', + title: 'Unlock & Clip article to rNotes', + contexts: ['page', 'link'], + }); }); // --- Helpers --- @@ -132,6 +138,31 @@ async function uploadImage(imageUrl) { return response.json(); } +async function unlockArticle(url) { + const token = await getToken(); + if (!token) { + showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); + return null; + } + + const settings = await getSettings(); + const response = await fetch(`${settings.host}/api/articles/unlock`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ url }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Unlock failed: ${response.status} ${text}`); + } + + return response.json(); +} + // --- Context Menu Handler --- chrome.contextMenus.onClicked.addListener(async (info, tab) => { @@ -197,6 +228,28 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { break; } + case 'unlock-article': { + const targetUrl = info.linkUrl || tab.url; + showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`); + + const result = await unlockArticle(targetUrl); + if (result && result.success && result.archiveUrl) { + // Create a CLIP note with the archive URL + await createNote({ + title: tab.title || 'Unlocked Article', + content: `

Unlocked via ${result.strategy}

Original: ${targetUrl}

Archive: ${result.archiveUrl}

`, + type: 'CLIP', + url: targetUrl, + }); + showNotification('Article Unlocked', `Readable version found via ${result.strategy}`); + // Open the unlocked article in a new tab + chrome.tabs.create({ url: result.archiveUrl }); + } else { + showNotification('Unlock Failed', result?.error || 'No archived version found'); + } + break; + } + case 'clip-selection': { // Get selection HTML let content = ''; diff --git a/browser-extension/popup.html b/browser-extension/popup.html index d0db5ac..69b33a8 100644 --- a/browser-extension/popup.html +++ b/browser-extension/popup.html @@ -133,6 +133,14 @@ color: #e5e5e5; border: 1px solid #404040; } + .btn-unlock { + background: #172554; + color: #93c5fd; + border: 1px solid #1e40af; + } + .btn-unlock svg { + flex-shrink: 0; + } .status { margin: 0 14px 10px; @@ -212,6 +220,16 @@ +
+ +
+
+
+ +
+
+
+ + + +
+
Ready
+ +
00:00
+
+ +
+ +
+ +
+
Transcript
+
+ Transcribing... +
+
+ +
+ + +
+ + + +
+ +
+ Space to record · Esc to close +
+ + + + diff --git a/browser-extension/voice.js b/browser-extension/voice.js new file mode 100644 index 0000000..8dbe6c5 --- /dev/null +++ b/browser-extension/voice.js @@ -0,0 +1,414 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- State --- +let state = 'idle'; // idle | recording | processing | done +let mediaRecorder = null; +let audioChunks = []; +let timerInterval = null; +let startTime = 0; +let audioBlob = null; +let audioUrl = null; +let transcript = ''; +let uploadedFileUrl = ''; +let uploadedMimeType = ''; +let uploadedFileSize = 0; +let duration = 0; + +// --- DOM refs --- +const recBtn = document.getElementById('recBtn'); +const timerEl = document.getElementById('timer'); +const statusLabel = document.getElementById('statusLabel'); +const transcriptArea = document.getElementById('transcriptArea'); +const transcriptText = document.getElementById('transcriptText'); +const audioPreview = document.getElementById('audioPreview'); +const audioPlayer = document.getElementById('audioPlayer'); +const notebookSelect = document.getElementById('notebook'); +const postActions = document.getElementById('postActions'); +const saveBtn = document.getElementById('saveBtn'); +const discardBtn = document.getElementById('discardBtn'); +const copyBtn = document.getElementById('copyBtn'); +const statusBar = document.getElementById('statusBar'); +const authWarning = document.getElementById('authWarning'); +const closeBtn = document.getElementById('closeBtn'); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { host: result.rnotesHost || DEFAULT_HOST }; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +function decodeToken(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + if (payload.exp && payload.exp * 1000 < Date.now()) return null; + return payload; + } catch { return null; } +} + +function formatTime(seconds) { + const m = Math.floor(seconds / 60).toString().padStart(2, '0'); + const s = (seconds % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +} + +function setStatusLabel(text, cls) { + statusLabel.textContent = text; + statusLabel.className = `status-label ${cls}`; +} + +function showStatusBar(message, type) { + statusBar.textContent = message; + statusBar.className = `status-bar visible ${type}`; + if (type === 'success') { + setTimeout(() => { statusBar.className = 'status-bar'; }, 3000); + } +} + +// --- Notebook loader --- + +async function loadNotebooks() { + const token = await getToken(); + if (!token) return; + const settings = await getSettings(); + + try { + const res = await fetch(`${settings.host}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) return; + const notebooks = await res.json(); + + for (const nb of notebooks) { + const opt = document.createElement('option'); + opt.value = nb.id; + opt.textContent = nb.title; + notebookSelect.appendChild(opt); + } + + // Restore last used + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) notebookSelect.value = lastNotebookId; + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +notebookSelect.addEventListener('change', (e) => { + chrome.storage.local.set({ lastNotebookId: e.target.value }); +}); + +// --- Recording --- + +async function startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : 'audio/webm'; + + mediaRecorder = new MediaRecorder(stream, { mimeType }); + audioChunks = []; + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) audioChunks.push(e.data); + }; + + mediaRecorder.start(1000); + startTime = Date.now(); + state = 'recording'; + + // UI updates + recBtn.classList.add('recording'); + timerEl.classList.add('recording'); + setStatusLabel('Recording', 'recording'); + postActions.style.display = 'none'; + audioPreview.classList.remove('visible'); + transcriptArea.classList.remove('visible'); + statusBar.className = 'status-bar'; + + timerInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + timerEl.textContent = formatTime(elapsed); + }, 1000); + + } catch (err) { + showStatusBar(err.message || 'Microphone access denied', 'error'); + } +} + +async function stopRecording() { + if (!mediaRecorder || mediaRecorder.state === 'inactive') return; + + clearInterval(timerInterval); + timerInterval = null; + duration = Math.floor((Date.now() - startTime) / 1000); + + state = 'processing'; + recBtn.classList.remove('recording'); + timerEl.classList.remove('recording'); + setStatusLabel('Processing...', 'processing'); + + // Stop recorder and collect blob + audioBlob = await new Promise((resolve) => { + mediaRecorder.onstop = () => { + mediaRecorder.stream.getTracks().forEach(t => t.stop()); + resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType })); + }; + mediaRecorder.stop(); + }); + + // Show audio preview + if (audioUrl) URL.revokeObjectURL(audioUrl); + audioUrl = URL.createObjectURL(audioBlob); + audioPlayer.src = audioUrl; + audioPreview.classList.add('visible'); + + // Show transcript area with placeholder + transcriptArea.classList.add('visible'); + transcriptText.innerHTML = 'Transcribing...'; + + // Upload audio file + const token = await getToken(); + const settings = await getSettings(); + + try { + showStatusBar('Uploading recording...', 'loading'); + + const uploadForm = new FormData(); + uploadForm.append('file', audioBlob, 'voice-note.webm'); + + const uploadRes = await fetch(`${settings.host}/api/uploads`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: uploadForm, + }); + + if (!uploadRes.ok) throw new Error('Upload failed'); + + const uploadResult = await uploadRes.json(); + uploadedFileUrl = uploadResult.url; + uploadedMimeType = uploadResult.mimeType; + uploadedFileSize = uploadResult.size; + + // Transcribe via batch API + showStatusBar('Transcribing...', 'loading'); + + const transcribeForm = new FormData(); + transcribeForm.append('audio', audioBlob, 'voice-note.webm'); + + const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: transcribeForm, + }); + + if (transcribeRes.ok) { + const transcribeResult = await transcribeRes.json(); + transcript = transcribeResult.text || ''; + } else { + transcript = ''; + console.warn('Transcription failed, saving without transcript'); + } + + // Show transcript (editable) + if (transcript) { + transcriptText.textContent = transcript; + } else { + transcriptText.innerHTML = 'No transcript available - you can type one here'; + } + + state = 'done'; + setStatusLabel('Done', 'done'); + postActions.style.display = 'flex'; + statusBar.className = 'status-bar'; + + } catch (err) { + showStatusBar(`Error: ${err.message}`, 'error'); + state = 'done'; + setStatusLabel('Error', 'idle'); + postActions.style.display = 'flex'; + } +} + +function toggleRecording() { + if (state === 'idle' || state === 'done') { + startRecording(); + } else if (state === 'recording') { + stopRecording(); + } + // Ignore clicks while processing +} + +// --- Save to rNotes --- + +async function saveToRNotes() { + saveBtn.disabled = true; + showStatusBar('Saving to rNotes...', 'loading'); + + const token = await getToken(); + const settings = await getSettings(); + + // Get current transcript text (user may have edited it) + const editedTranscript = transcriptText.textContent.trim(); + const isPlaceholder = transcriptText.querySelector('.placeholder') !== null; + const finalTranscript = isPlaceholder ? '' : editedTranscript; + + const now = new Date(); + const timeStr = now.toLocaleString('en-US', { + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', + hour12: true + }); + + const body = { + title: `Voice note - ${timeStr}`, + content: finalTranscript + ? `

${finalTranscript.replace(/\n/g, '

')}

` + : '

Voice recording (no transcript)

', + type: 'AUDIO', + mimeType: uploadedMimeType || 'audio/webm', + fileUrl: uploadedFileUrl, + fileSize: uploadedFileSize, + duration: duration, + tags: ['voice'], + }; + + const notebookId = notebookSelect.value; + if (notebookId) body.notebookId = notebookId; + + try { + const res = await fetch(`${settings.host}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status}: ${text}`); + } + + showStatusBar('Saved to rNotes!', 'success'); + + // Notify + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Voice Note Saved', + message: `${formatTime(duration)} recording saved to rNotes`, + }); + + // Reset after short delay + setTimeout(resetState, 1500); + + } catch (err) { + showStatusBar(`Save failed: ${err.message}`, 'error'); + } finally { + saveBtn.disabled = false; + } +} + +// --- Copy to clipboard --- + +async function copyTranscript() { + const text = transcriptText.textContent.trim(); + if (!text || transcriptText.querySelector('.placeholder')) { + showStatusBar('No transcript to copy', 'error'); + return; + } + try { + await navigator.clipboard.writeText(text); + showStatusBar('Copied to clipboard', 'success'); + } catch { + showStatusBar('Copy failed', 'error'); + } +} + +// --- Discard --- + +function resetState() { + state = 'idle'; + mediaRecorder = null; + audioChunks = []; + audioBlob = null; + transcript = ''; + uploadedFileUrl = ''; + uploadedMimeType = ''; + uploadedFileSize = 0; + duration = 0; + + if (audioUrl) { + URL.revokeObjectURL(audioUrl); + audioUrl = null; + } + + timerEl.textContent = '00:00'; + timerEl.classList.remove('recording'); + recBtn.classList.remove('recording'); + setStatusLabel('Ready', 'idle'); + postActions.style.display = 'none'; + audioPreview.classList.remove('visible'); + transcriptArea.classList.remove('visible'); + statusBar.className = 'status-bar'; +} + +// --- Keyboard shortcuts --- + +document.addEventListener('keydown', (e) => { + // Space bar: toggle recording (unless editing transcript) + if (e.code === 'Space' && document.activeElement !== transcriptText) { + e.preventDefault(); + toggleRecording(); + } + // Escape: close window + if (e.code === 'Escape') { + window.close(); + } + // Ctrl+Enter: save (when in done state) + if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { + e.preventDefault(); + saveToRNotes(); + } +}); + +// Clear placeholder on focus +transcriptText.addEventListener('focus', () => { + const ph = transcriptText.querySelector('.placeholder'); + if (ph) transcriptText.textContent = ''; +}); + +// --- Event listeners --- + +recBtn.addEventListener('click', toggleRecording); +saveBtn.addEventListener('click', saveToRNotes); +discardBtn.addEventListener('click', resetState); +copyBtn.addEventListener('click', copyTranscript); +closeBtn.addEventListener('click', () => window.close()); + +// --- Init --- + +async function init() { + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + + if (!claims) { + authWarning.style.display = 'block'; + recBtn.style.opacity = '0.3'; + recBtn.style.pointerEvents = 'none'; + return; + } + + authWarning.style.display = 'none'; + await loadNotebooks(); +} + +document.addEventListener('DOMContentLoaded', init); From 5ff6c9d832ac736b958fcefe5ef13621c75208a3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 16:43:57 -0800 Subject: [PATCH 4/5] feat: add rVoice popup recorder with 3-tier transcription to browser extension Adds a standalone voice recording popup (voice.html) accessible via the extension popup button or Ctrl+Shift+V hotkey. Records audio, uploads to rNotes, and transcribes with a 3-tier cascade: server Whisper API, live Web Speech API (real-time text while recording), and offline Parakeet.js (NVIDIA 0.6B, ~634MB cached in IndexedDB). Saves as AUDIO notes with editable transcript and notebook selection. Co-Authored-By: Claude Opus 4.6 --- browser-extension/manifest.json | 3 + browser-extension/parakeet-offline.js | 147 ++++++++++++++++ browser-extension/voice.html | 74 +++++++- browser-extension/voice.js | 236 +++++++++++++++++++++++--- 4 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 browser-extension/parakeet-offline.js diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 0f615d9..7317a84 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -35,6 +35,9 @@ "page": "options.html", "open_in_tab": false }, + "content_security_policy": { + "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'" + }, "commands": { "open-voice-recorder": { "suggested_key": { diff --git a/browser-extension/parakeet-offline.js b/browser-extension/parakeet-offline.js new file mode 100644 index 0000000..2aa4443 --- /dev/null +++ b/browser-extension/parakeet-offline.js @@ -0,0 +1,147 @@ +/** + * Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2). + * Loaded at runtime from CDN. Model ~634 MB (int8) on first download, + * cached in IndexedDB after. Works fully offline after first download. + * + * Port of src/lib/parakeetOffline.ts for the browser extension. + */ + +const CACHE_KEY = 'parakeet-offline-cached'; + +// Singleton model — don't reload on subsequent calls +let cachedModel = null; +let loadingPromise = null; + +/** + * Check if the Parakeet model has been downloaded before. + */ +function isModelCached() { + try { + return localStorage.getItem(CACHE_KEY) === 'true'; + } catch { + return false; + } +} + +/** + * Detect WebGPU availability. + */ +async function detectWebGPU() { + if (!navigator.gpu) return false; + try { + const adapter = await navigator.gpu.requestAdapter(); + return !!adapter; + } catch { + return false; + } +} + +/** + * Get or create the Parakeet model singleton. + * @param {function} onProgress - callback({ status, progress, file, message }) + */ +async function getModel(onProgress) { + if (cachedModel) return cachedModel; + if (loadingPromise) return loadingPromise; + + loadingPromise = (async () => { + onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' }); + + // Dynamic import from CDN at runtime + const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2'); + + const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm'; + const fileProgress = {}; + + const model = await fromHub('parakeet-tdt-0.6b-v2', { + backend, + progress: ({ file, loaded, total }) => { + 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 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) { + 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. + * + * @param {Blob} audioBlob + * @param {function} onProgress - callback({ status, progress, file, message }) + * @returns {Promise} transcribed text + */ +async function transcribeOffline(audioBlob, onProgress) { + 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; +} + +// Export for use in voice.js (loaded as ES module) +window.ParakeetOffline = { + isModelCached, + transcribeOffline, +}; diff --git a/browser-extension/voice.html b/browser-extension/voice.html index ecacc3f..0da0f25 100644 --- a/browser-extension/voice.html +++ b/browser-extension/voice.html @@ -175,6 +175,13 @@ color: #525252; font-style: italic; } + .transcript-text .final-text { + color: #d4d4d4; + } + .transcript-text .interim-text { + color: #737373; + font-style: italic; + } /* Controls row */ .controls { @@ -255,6 +262,61 @@ .status-bar.error { color: #fca5a5; background: #450a0a; border-top-color: #991b1b; } .status-bar.loading { color: #93c5fd; background: #172554; border-top-color: #1e40af; } + /* Live indicator */ + .live-indicator { + display: none; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + color: #4ade80; + } + .live-indicator.visible { + display: flex; + } + .live-indicator .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; + animation: pulse-dot 1s infinite; + } + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + + /* Progress bar (for model download) */ + .progress-area { + width: 100%; + padding: 0 14px 8px; + display: none; + } + .progress-area.visible { + display: block; + } + .progress-label { + font-size: 11px; + color: #a3a3a3; + margin-bottom: 4px; + } + .progress-bar { + width: 100%; + height: 6px; + background: #262626; + border-radius: 3px; + overflow: hidden; + } + .progress-bar .fill { + height: 100%; + background: #f59e0b; + border-radius: 3px; + transition: width 0.3s; + width: 0%; + } + /* Audio preview */ .audio-preview { width: 100%; @@ -305,6 +367,15 @@
00:00
+
+ + Live transcribe +
+ + +
+
Loading model...
+
@@ -334,9 +405,10 @@
- Space to record · Esc to close + Space to record · Esc to close · Offline ready
+ diff --git a/browser-extension/voice.js b/browser-extension/voice.js index 8dbe6c5..9c94767 100644 --- a/browser-extension/voice.js +++ b/browser-extension/voice.js @@ -9,17 +9,23 @@ let startTime = 0; let audioBlob = null; let audioUrl = null; let transcript = ''; +let liveTranscript = ''; // accumulated from Web Speech API let uploadedFileUrl = ''; let uploadedMimeType = ''; let uploadedFileSize = 0; let duration = 0; +// Web Speech API +let recognition = null; +let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition); + // --- DOM refs --- const recBtn = document.getElementById('recBtn'); const timerEl = document.getElementById('timer'); const statusLabel = document.getElementById('statusLabel'); const transcriptArea = document.getElementById('transcriptArea'); const transcriptText = document.getElementById('transcriptText'); +const liveIndicator = document.getElementById('liveIndicator'); const audioPreview = document.getElementById('audioPreview'); const audioPlayer = document.getElementById('audioPlayer'); const notebookSelect = document.getElementById('notebook'); @@ -70,6 +76,36 @@ function showStatusBar(message, type) { } } +// --- Parakeet progress UI --- + +const progressArea = document.getElementById('progressArea'); +const progressLabel = document.getElementById('progressLabel'); +const progressFill = document.getElementById('progressFill'); + +function showParakeetProgress(p) { + if (!progressArea) return; + progressArea.classList.add('visible'); + + if (p.message) { + progressLabel.textContent = p.message; + } + + if (p.status === 'downloading' && p.progress !== undefined) { + progressFill.style.width = `${p.progress}%`; + } else if (p.status === 'transcribing') { + progressFill.style.width = '100%'; + } else if (p.status === 'loading') { + progressFill.style.width = '0%'; + } +} + +function hideParakeetProgress() { + if (progressArea) { + progressArea.classList.remove('visible'); + progressFill.style.width = '0%'; + } +} + // --- Notebook loader --- async function loadNotebooks() { @@ -103,6 +139,97 @@ notebookSelect.addEventListener('change', (e) => { chrome.storage.local.set({ lastNotebookId: e.target.value }); }); +// --- Live transcription (Web Speech API) --- + +function startLiveTranscription() { + if (!speechSupported) return; + + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; + + let finalizedText = ''; + + recognition.onresult = (event) => { + let interimText = ''; + // Rebuild finalized text from all final results + finalizedText = ''; + for (let i = 0; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalizedText += result[0].transcript.trim() + ' '; + } else { + interimText += result[0].transcript; + } + } + + liveTranscript = finalizedText.trim(); + + // Update the live transcript display + updateLiveDisplay(finalizedText.trim(), interimText.trim()); + }; + + recognition.onerror = (event) => { + if (event.error !== 'aborted' && event.error !== 'no-speech') { + console.warn('Speech recognition error:', event.error); + } + }; + + // Auto-restart on end (Chrome stops after ~60s of silence) + recognition.onend = () => { + if (state === 'recording' && recognition) { + try { recognition.start(); } catch {} + } + }; + + try { + recognition.start(); + if (liveIndicator) liveIndicator.classList.add('visible'); + } catch (err) { + console.warn('Could not start speech recognition:', err); + speechSupported = false; + } +} + +function stopLiveTranscription() { + if (recognition) { + const ref = recognition; + recognition = null; + try { ref.stop(); } catch {} + } + if (liveIndicator) liveIndicator.classList.remove('visible'); +} + +function updateLiveDisplay(finalText, interimText) { + if (state !== 'recording') return; + + // Show transcript area while recording + transcriptArea.classList.add('visible'); + + let html = ''; + if (finalText) { + html += `${escapeHtml(finalText)}`; + } + if (interimText) { + html += `${escapeHtml(interimText)}`; + } + if (!finalText && !interimText) { + html = 'Listening...'; + } + transcriptText.innerHTML = html; + + // Auto-scroll + transcriptText.scrollTop = transcriptText.scrollHeight; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + // --- Recording --- async function startRecording() { @@ -115,6 +242,7 @@ async function startRecording() { mediaRecorder = new MediaRecorder(stream, { mimeType }); audioChunks = []; + liveTranscript = ''; mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); @@ -130,14 +258,24 @@ async function startRecording() { setStatusLabel('Recording', 'recording'); postActions.style.display = 'none'; audioPreview.classList.remove('visible'); - transcriptArea.classList.remove('visible'); statusBar.className = 'status-bar'; + // Show transcript area with listening placeholder + if (speechSupported) { + transcriptArea.classList.add('visible'); + transcriptText.innerHTML = 'Listening...'; + } else { + transcriptArea.classList.remove('visible'); + } + timerInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); timerEl.textContent = formatTime(elapsed); }, 1000); + // Start live transcription alongside recording + startLiveTranscription(); + } catch (err) { showStatusBar(err.message || 'Microphone access denied', 'error'); } @@ -150,6 +288,12 @@ async function stopRecording() { timerInterval = null; duration = Math.floor((Date.now() - startTime) / 1000); + // Capture live transcript before stopping recognition + const capturedLiveTranscript = liveTranscript; + + // Stop live transcription + stopLiveTranscription(); + state = 'processing'; recBtn.classList.remove('recording'); timerEl.classList.remove('recording'); @@ -170,17 +314,21 @@ async function stopRecording() { audioPlayer.src = audioUrl; audioPreview.classList.add('visible'); - // Show transcript area with placeholder + // Show live transcript while we process (if we have one) transcriptArea.classList.add('visible'); - transcriptText.innerHTML = 'Transcribing...'; + if (capturedLiveTranscript) { + transcriptText.textContent = capturedLiveTranscript; + showStatusBar('Improving transcript...', 'loading'); + } else { + transcriptText.innerHTML = 'Transcribing...'; + showStatusBar('Uploading & transcribing...', 'loading'); + } // Upload audio file const token = await getToken(); const settings = await getSettings(); try { - showStatusBar('Uploading recording...', 'loading'); - const uploadForm = new FormData(); uploadForm.append('file', audioBlob, 'voice-note.webm'); @@ -197,26 +345,50 @@ async function stopRecording() { uploadedMimeType = uploadResult.mimeType; uploadedFileSize = uploadResult.size; - // Transcribe via batch API - showStatusBar('Transcribing...', 'loading'); + // --- Three-tier transcription cascade --- - const transcribeForm = new FormData(); - transcribeForm.append('audio', audioBlob, 'voice-note.webm'); + // Tier 1: Batch API (Whisper on server — highest quality) + let bestTranscript = ''; + try { + showStatusBar('Transcribing via server...', 'loading'); + const transcribeForm = new FormData(); + transcribeForm.append('audio', audioBlob, 'voice-note.webm'); - const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: transcribeForm, - }); + const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: transcribeForm, + }); - if (transcribeRes.ok) { - const transcribeResult = await transcribeRes.json(); - transcript = transcribeResult.text || ''; - } else { - transcript = ''; - console.warn('Transcription failed, saving without transcript'); + if (transcribeRes.ok) { + const transcribeResult = await transcribeRes.json(); + bestTranscript = transcribeResult.text || ''; + } + } catch { + console.warn('Tier 1 (batch API) unavailable'); } + // Tier 2: Live transcript from Web Speech API (already captured) + if (!bestTranscript && capturedLiveTranscript) { + bestTranscript = capturedLiveTranscript; + } + + // Tier 3: Offline Parakeet.js (NVIDIA, runs in browser) + if (!bestTranscript && window.ParakeetOffline) { + try { + showStatusBar('Transcribing offline (Parakeet)...', 'loading'); + bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => { + showParakeetProgress(p); + }); + hideParakeetProgress(); + } catch (offlineErr) { + console.warn('Tier 3 (Parakeet offline) failed:', offlineErr); + hideParakeetProgress(); + } + } + + transcript = bestTranscript; + // Show transcript (editable) if (transcript) { transcriptText.textContent = transcript; @@ -230,6 +402,26 @@ async function stopRecording() { statusBar.className = 'status-bar'; } catch (err) { + // On upload error, try offline transcription directly + let fallbackTranscript = capturedLiveTranscript || ''; + + if (!fallbackTranscript && window.ParakeetOffline) { + try { + showStatusBar('Upload failed, transcribing offline...', 'loading'); + fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => { + showParakeetProgress(p); + }); + hideParakeetProgress(); + } catch { + hideParakeetProgress(); + } + } + + transcript = fallbackTranscript; + if (transcript) { + transcriptText.textContent = transcript; + } + showStatusBar(`Error: ${err.message}`, 'error'); state = 'done'; setStatusLabel('Error', 'idle'); @@ -341,11 +533,14 @@ function resetState() { audioChunks = []; audioBlob = null; transcript = ''; + liveTranscript = ''; uploadedFileUrl = ''; uploadedMimeType = ''; uploadedFileSize = 0; duration = 0; + stopLiveTranscription(); + if (audioUrl) { URL.revokeObjectURL(audioUrl); audioUrl = null; @@ -358,6 +553,7 @@ function resetState() { postActions.style.display = 'none'; audioPreview.classList.remove('visible'); transcriptArea.classList.remove('visible'); + hideParakeetProgress(); statusBar.className = 'status-bar'; } From d236b81a11f72fbddc862b7f114d90c3dfd3e101 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 17:43:04 -0800 Subject: [PATCH 5/5] feat: add /voice PWA route with 3-tier live transcription Dedicated standalone voice recorder page at /voice that works as an installable PWA. Records audio with three transcription tiers running in parallel: WebSocket streaming (live segments), Web Speech API (live local), and batch Whisper API (high quality). Falls back to offline Parakeet.js if all network tiers fail. Includes editable transcript, notebook selection, copy-to-clipboard, and keyboard shortcuts. PWA manifest updated with Voice Note shortcut for quick access from taskbar right-click menu. Co-Authored-By: Claude Opus 4.6 --- public/manifest.json | 28 ++ src/app/voice/page.tsx | 747 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 src/app/voice/page.tsx diff --git a/public/manifest.json b/public/manifest.json index 127797d..f3916a3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -35,5 +35,33 @@ "type": "image/png", "purpose": "maskable" } + ], + "shortcuts": [ + { + "name": "Voice Note", + "short_name": "Voice", + "description": "Record a voice note with live transcription", + "url": "/voice", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "New Note", + "short_name": "Note", + "description": "Create a new note", + "url": "/notes/new", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + } ] } diff --git a/src/app/voice/page.tsx b/src/app/voice/page.tsx new file mode 100644 index 0000000..90c892a --- /dev/null +++ b/src/app/voice/page.tsx @@ -0,0 +1,747 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { authFetch } from '@/lib/authFetch'; + +// --- Types --- + +interface Segment { + id: number; + text: string; + start: number; + end: number; +} + +interface WhisperProgress { + status: 'checking' | 'downloading' | 'loading' | 'transcribing' | 'done' | 'error'; + progress?: number; + message?: string; +} + +interface NotebookOption { + id: string; + title: string; +} + +type RecorderState = 'idle' | 'recording' | 'processing' | 'done'; + +// --- Constants --- + +const VOICE_WS_URL = + process.env.NEXT_PUBLIC_VOICE_WS_URL || 'wss://voice.jeffemmett.com'; + +// Web Speech API types +interface ISpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((event: any) => void) | null; + onerror: ((event: any) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; +} + +function getSpeechRecognition(): (new () => ISpeechRecognition) | null { + if (typeof window === 'undefined') return null; + return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; +} + +// --- Component --- + +export default function VoicePage() { + const router = useRouter(); + + // Recording state + const [state, setState] = useState('idle'); + const [elapsed, setElapsed] = useState(0); + const [streaming, setStreaming] = useState(false); + + // Transcript + const [segments, setSegments] = useState([]); + const [liveText, setLiveText] = useState(''); + const [interimText, setInterimText] = useState(''); + const [finalTranscript, setFinalTranscript] = useState(''); + const [isEditing, setIsEditing] = useState(false); + + // Audio + const [audioUrl, setAudioUrl] = useState(null); + const [duration, setDuration] = useState(0); + + // Upload state + const [uploadedFileUrl, setUploadedFileUrl] = useState(''); + const [uploadedMimeType, setUploadedMimeType] = useState(''); + const [uploadedFileSize, setUploadedFileSize] = useState(0); + + // UI + const [notebooks, setNotebooks] = useState([]); + const [notebookId, setNotebookId] = useState(''); + const [status, setStatus] = useState<{ message: string; type: 'success' | 'error' | 'loading' } | null>(null); + const [offlineProgress, setOfflineProgress] = useState(null); + const [saving, setSaving] = useState(false); + + // Refs + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const audioBlobRef = useRef(null); + const timerRef = useRef | null>(null); + const startTimeRef = useRef(0); + const recognitionRef = useRef(null); + const liveTextRef = useRef(''); + const segmentsRef = useRef([]); + const wsRef = useRef(null); + const audioContextRef = useRef(null); + const workletNodeRef = useRef(null); + const sourceNodeRef = useRef(null); + const transcriptRef = useRef(null); + const editRef = useRef(null); + + // Load notebooks + useEffect(() => { + authFetch('/api/notebooks') + .then((res) => res.json()) + .then((data) => { + if (Array.isArray(data)) { + setNotebooks(data.map((nb: any) => ({ id: nb.id, title: nb.title }))); + } + }) + .catch(() => {}); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearInterval(timerRef.current); + if (audioUrl) URL.revokeObjectURL(audioUrl); + }; + }, [audioUrl]); + + // Auto-scroll transcript + useEffect(() => { + if (transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + } + }, [segments, liveText, interimText]); + + const formatTime = (s: number) => { + const m = Math.floor(s / 60).toString().padStart(2, '0'); + const sec = (s % 60).toString().padStart(2, '0'); + return `${m}:${sec}`; + }; + + // --- WebSocket live streaming --- + + const setupWebSocket = useCallback(async (stream: MediaStream) => { + try { + const ws = new WebSocket(`${VOICE_WS_URL}/api/voice/stream`); + wsRef.current = ws; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { ws.close(); reject(new Error('timeout')); }, 5000); + ws.onopen = () => { clearTimeout(timeout); resolve(); }; + ws.onerror = () => { clearTimeout(timeout); reject(new Error('failed')); }; + }); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'segment') { + const seg = { id: data.id, text: data.text, start: data.start, end: data.end }; + segmentsRef.current = [...segmentsRef.current, seg]; + setSegments([...segmentsRef.current]); + } + } catch {} + }; + + // AudioWorklet for PCM16 streaming at 16kHz + const audioCtx = new AudioContext({ sampleRate: 16000 }); + audioContextRef.current = audioCtx; + const source = audioCtx.createMediaStreamSource(stream); + sourceNodeRef.current = source; + + await audioCtx.audioWorklet.addModule('/pcm-processor.js'); + const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor'); + workletNodeRef.current = workletNode; + + workletNode.port.onmessage = (e) => { + if (ws.readyState === WebSocket.OPEN) ws.send(e.data as ArrayBuffer); + }; + + source.connect(workletNode); + setStreaming(true); + } catch { + setStreaming(false); + } + }, []); + + // --- Web Speech API (live local) --- + + const startSpeechRecognition = useCallback(() => { + const SpeechRecognition = getSpeechRecognition(); + if (!SpeechRecognition) return; + + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; + + recognition.onresult = (event: any) => { + let finalized = ''; + let interim = ''; + for (let i = 0; i < event.results.length; i++) { + if (event.results[i].isFinal) { + finalized += event.results[i][0].transcript.trim() + ' '; + } else { + interim += event.results[i][0].transcript; + } + } + liveTextRef.current = finalized.trim(); + setLiveText(finalized.trim()); + setInterimText(interim.trim()); + }; + + recognition.onerror = () => {}; + recognition.onend = () => { + // Auto-restart (Chrome stops after ~60s silence) + if (recognitionRef.current === recognition) { + try { recognition.start(); } catch {} + } + }; + + recognitionRef.current = recognition; + try { recognition.start(); } catch {} + }, []); + + const stopSpeechRecognition = useCallback(() => { + if (recognitionRef.current) { + const ref = recognitionRef.current; + recognitionRef.current = null; + try { ref.stop(); } catch {} + } + setInterimText(''); + }, []); + + // --- Cleanup streaming --- + + const cleanupStreaming = useCallback(() => { + if (workletNodeRef.current) { workletNodeRef.current.disconnect(); workletNodeRef.current = null; } + if (sourceNodeRef.current) { sourceNodeRef.current.disconnect(); sourceNodeRef.current = null; } + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.close().catch(() => {}); + audioContextRef.current = null; + } + if (wsRef.current) { + if (wsRef.current.readyState === WebSocket.OPEN) wsRef.current.close(); + wsRef.current = null; + } + setStreaming(false); + }, []); + + // --- Start recording --- + + const startRecording = useCallback(async () => { + setSegments([]); + segmentsRef.current = []; + setLiveText(''); + liveTextRef.current = ''; + setInterimText(''); + setFinalTranscript(''); + setIsEditing(false); + setStatus(null); + setOfflineProgress(null); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : 'audio/webm'; + + const recorder = new MediaRecorder(stream, { mimeType }); + chunksRef.current = []; + recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; + recorder.start(1000); + mediaRecorderRef.current = recorder; + + startTimeRef.current = Date.now(); + setState('recording'); + setElapsed(0); + timerRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); + }, 1000); + + // Start both transcription methods in parallel + setupWebSocket(stream); + startSpeechRecognition(); + + } catch (err) { + setStatus({ message: err instanceof Error ? err.message : 'Microphone access denied', type: 'error' }); + } + }, [setupWebSocket, startSpeechRecognition]); + + // --- Stop recording --- + + const stopRecording = useCallback(async () => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state === 'inactive') return; + + if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } + const dur = Math.floor((Date.now() - startTimeRef.current) / 1000); + setDuration(dur); + + // Capture live text before stopping + const capturedLive = liveTextRef.current; + stopSpeechRecognition(); + + // Get WS final text + let wsFullText = ''; + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + const ws = wsRef.current; + wsFullText = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(''), 5000); + const handler = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'segment') { + const seg = { id: data.id, text: data.text, start: data.start, end: data.end }; + segmentsRef.current = [...segmentsRef.current, seg]; + setSegments([...segmentsRef.current]); + } + if (data.type === 'done') { + clearTimeout(timeout); + ws.removeEventListener('message', handler); + resolve(data.fullText || ''); + } + } catch {} + }; + ws.addEventListener('message', handler); + ws.send(JSON.stringify({ type: 'end' })); + }); + } catch {} + } + cleanupStreaming(); + + setState('processing'); + + // Stop recorder + const blob = await new Promise((resolve) => { + recorder.onstop = () => { + recorder.stream.getTracks().forEach((t) => t.stop()); + resolve(new Blob(chunksRef.current, { type: recorder.mimeType })); + }; + recorder.stop(); + }); + audioBlobRef.current = blob; + + if (audioUrl) URL.revokeObjectURL(audioUrl); + const url = URL.createObjectURL(blob); + setAudioUrl(url); + + // --- Three-tier transcription cascade --- + + // Show immediate live text while we process + const immediateLive = wsFullText || (segmentsRef.current.length > 0 + ? segmentsRef.current.map(s => s.text).join(' ') + : capturedLive); + if (immediateLive) setFinalTranscript(immediateLive); + + // Tier 1: Upload + batch API + let bestTranscript = ''; + try { + setStatus({ message: 'Uploading recording...', type: 'loading' }); + const uploadForm = new FormData(); + uploadForm.append('file', blob, 'voice-note.webm'); + const uploadRes = await authFetch('/api/uploads', { method: 'POST', body: uploadForm }); + + if (uploadRes.ok) { + const uploadResult = await uploadRes.json(); + setUploadedFileUrl(uploadResult.url); + setUploadedMimeType(uploadResult.mimeType); + setUploadedFileSize(uploadResult.size); + + setStatus({ message: 'Transcribing...', type: 'loading' }); + const tForm = new FormData(); + tForm.append('audio', blob, 'voice-note.webm'); + const tRes = await authFetch('/api/voice/transcribe', { method: 'POST', body: tForm }); + if (tRes.ok) { + const tResult = await tRes.json(); + bestTranscript = tResult.text || ''; + } + } + } catch { + console.warn('Tier 1 (batch API) failed'); + } + + // Tier 2: WebSocket / Web Speech API (already captured) + if (!bestTranscript) bestTranscript = immediateLive || ''; + + // Tier 3: Offline Parakeet.js + if (!bestTranscript) { + try { + setStatus({ message: 'Loading offline model...', type: 'loading' }); + const { transcribeOffline } = await import('@/lib/parakeetOffline'); + bestTranscript = await transcribeOffline(blob, (p) => setOfflineProgress(p)); + setOfflineProgress(null); + } catch { + setOfflineProgress(null); + } + } + + setFinalTranscript(bestTranscript); + setStatus(null); + setState('done'); + }, [audioUrl, stopSpeechRecognition, cleanupStreaming]); + + // --- Toggle --- + + const toggleRecording = useCallback(() => { + if (state === 'idle' || state === 'done') startRecording(); + else if (state === 'recording') stopRecording(); + }, [state, startRecording, stopRecording]); + + // --- Save --- + + const saveToRNotes = useCallback(async () => { + setSaving(true); + setStatus({ message: 'Saving...', type: 'loading' }); + + const now = new Date(); + const timeStr = now.toLocaleString('en-US', { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true + }); + + const transcript = finalTranscript.trim(); + const body: Record = { + title: `Voice note - ${timeStr}`, + content: transcript + ? `

${transcript.replace(/\n/g, '

')}

` + : '

Voice recording (no transcript)

', + type: 'AUDIO', + mimeType: uploadedMimeType || 'audio/webm', + fileUrl: uploadedFileUrl, + fileSize: uploadedFileSize, + duration, + tags: ['voice'], + }; + if (notebookId) body.notebookId = notebookId; + + // If upload failed earlier, try uploading now + if (!uploadedFileUrl && audioBlobRef.current) { + try { + const form = new FormData(); + form.append('file', audioBlobRef.current, 'voice-note.webm'); + const res = await authFetch('/api/uploads', { method: 'POST', body: form }); + if (res.ok) { + const result = await res.json(); + body.fileUrl = result.url; + body.mimeType = result.mimeType; + body.fileSize = result.size; + } + } catch {} + } + + try { + const res = await authFetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error('Save failed'); + + const note = await res.json(); + setStatus({ message: 'Saved!', type: 'success' }); + setTimeout(() => router.push(`/notes/${note.id}`), 1000); + } catch (err) { + setStatus({ message: err instanceof Error ? err.message : 'Save failed', type: 'error' }); + } finally { + setSaving(false); + } + }, [finalTranscript, uploadedFileUrl, uploadedMimeType, uploadedFileSize, duration, notebookId, router]); + + // --- Copy --- + + const copyTranscript = useCallback(async () => { + if (!finalTranscript.trim()) return; + try { + await navigator.clipboard.writeText(finalTranscript); + setStatus({ message: 'Copied!', type: 'success' }); + setTimeout(() => setStatus(null), 2000); + } catch { + setStatus({ message: 'Copy failed', type: 'error' }); + } + }, [finalTranscript]); + + // --- Reset --- + + const discard = useCallback(() => { + setState('idle'); + setSegments([]); + segmentsRef.current = []; + setLiveText(''); + liveTextRef.current = ''; + setInterimText(''); + setFinalTranscript(''); + setIsEditing(false); + setElapsed(0); + setDuration(0); + setStatus(null); + setOfflineProgress(null); + setUploadedFileUrl(''); + setUploadedMimeType(''); + setUploadedFileSize(0); + if (audioUrl) { URL.revokeObjectURL(audioUrl); setAudioUrl(null); } + audioBlobRef.current = null; + }, [audioUrl]); + + // --- Keyboard --- + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable) return; + + if (e.code === 'Space') { + e.preventDefault(); + toggleRecording(); + } + if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') { + e.preventDefault(); + saveToRNotes(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [toggleRecording, saveToRNotes, state]); + + // --- Render --- + + const hasLiveText = liveText || interimText || segments.length > 0; + const hasTranscript = state === 'done' && finalTranscript.trim().length > 0; + + return ( +
+ {/* Header */} +
+
+
+ + + + +
+
+

rVoice

+

Voice notes for rNotes

+
+
+
+ {streaming && ( + + + Live + + )} + {getSpeechRecognition() && state === 'recording' && !streaming && ( + + + Local + + )} +
+
+ + {/* Main content */} +
+ + {/* Record button + timer */} +
+ + +
+ {formatTime(state === 'done' ? duration : elapsed)} +
+ +

+ {state === 'idle' && 'Tap to record or press Space'} + {state === 'recording' && 'Recording... tap to stop'} + {state === 'processing' && (offlineProgress?.message || 'Processing...')} + {state === 'done' && 'Recording complete'} +

+
+ + {/* Offline model progress bar */} + {offlineProgress && offlineProgress.status === 'downloading' && ( +
+
{offlineProgress.message}
+
+
+
+
+ )} + + {/* Live transcript (while recording) */} + {state === 'recording' && hasLiveText && ( +
+
Live transcript
+
+ {segments.length > 0 && ( +
+ {segments.map((seg) => ( +

{seg.text}

+ ))} +
+ )} + {segments.length === 0 && liveText && ( +

{liveText}

+ )} + {interimText && ( +

{interimText}

+ )} +
+
+ )} + + {/* Audio player + transcript (after recording) */} + {(state === 'done' || state === 'processing') && audioUrl && ( +
+