rspace-online/browser-extension/voice.js

580 lines
16 KiB
JavaScript

const DEFAULT_HOST = 'https://rspace.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 liveTranscript = '';
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');
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(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
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);
}
}
// --- 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() {
const token = await getToken();
if (!token) return;
const settings = await getSettings();
if (!settings.slug) return;
try {
const res = await fetch(`${apiBase(settings)}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
const notebooks = data.notebooks || (Array.isArray(data) ? data : []);
for (const nb of notebooks) {
const opt = document.createElement('option');
opt.value = nb.id;
opt.textContent = nb.title;
notebookSelect.appendChild(opt);
}
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 });
});
// --- 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 = '';
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();
updateLiveDisplay(finalizedText.trim(), interimText.trim());
};
recognition.onerror = (event) => {
if (event.error !== 'aborted' && event.error !== 'no-speech') {
console.warn('Speech recognition error:', event.error);
}
};
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;
transcriptArea.classList.add('visible');
let html = '';
if (finalText) html += `<span class="final-text">${escapeHtml(finalText)}</span>`;
if (interimText) html += `<span class="interim-text">${escapeHtml(interimText)}</span>`;
if (!finalText && !interimText) html = '<span class="placeholder">Listening...</span>';
transcriptText.innerHTML = html;
transcriptText.scrollTop = transcriptText.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- 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 = [];
liveTranscript = '';
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.start(1000);
startTime = Date.now();
state = 'recording';
recBtn.classList.add('recording');
timerEl.classList.add('recording');
setStatusLabel('Recording', 'recording');
postActions.style.display = 'none';
audioPreview.classList.remove('visible');
statusBar.className = 'status-bar';
if (speechSupported) {
transcriptArea.classList.add('visible');
transcriptText.innerHTML = '<span class="placeholder">Listening...</span>';
} else {
transcriptArea.classList.remove('visible');
}
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
timerEl.textContent = formatTime(elapsed);
}, 1000);
startLiveTranscription();
} 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);
const capturedLiveTranscript = liveTranscript;
stopLiveTranscription();
state = 'processing';
recBtn.classList.remove('recording');
timerEl.classList.remove('recording');
setStatusLabel('Processing...', 'processing');
audioBlob = await new Promise((resolve) => {
mediaRecorder.onstop = () => {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
};
mediaRecorder.stop();
});
if (audioUrl) URL.revokeObjectURL(audioUrl);
audioUrl = URL.createObjectURL(audioBlob);
audioPlayer.src = audioUrl;
audioPreview.classList.add('visible');
transcriptArea.classList.add('visible');
if (capturedLiveTranscript) {
transcriptText.textContent = capturedLiveTranscript;
showStatusBar('Improving transcript...', 'loading');
} else {
transcriptText.innerHTML = '<span class="placeholder">Transcribing...</span>';
showStatusBar('Uploading & transcribing...', 'loading');
}
const token = await getToken();
const settings = await getSettings();
try {
const uploadForm = new FormData();
uploadForm.append('file', audioBlob, 'voice-note.webm');
const uploadRes = await fetch(`${apiBase(settings)}/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;
// --- Three-tier transcription cascade ---
// Tier 1: Server (Whisper)
let bestTranscript = '';
try {
showStatusBar('Transcribing via server...', 'loading');
const transcribeForm = new FormData();
transcribeForm.append('audio', audioBlob, 'voice-note.webm');
const transcribeRes = await fetch(`${apiBase(settings)}/api/voice/transcribe`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: transcribeForm,
});
if (transcribeRes.ok) {
const transcribeResult = await transcribeRes.json();
bestTranscript = transcribeResult.text || '';
}
} catch {
console.warn('Tier 1 (batch API) unavailable');
}
// Tier 2: Web Speech API (already captured)
if (!bestTranscript && capturedLiveTranscript) {
bestTranscript = capturedLiveTranscript;
}
// Tier 3: Offline Parakeet.js
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;
if (transcript) {
transcriptText.textContent = transcript;
} else {
transcriptText.innerHTML = '<span class="placeholder">No transcript available - you can type one here</span>';
}
state = 'done';
setStatusLabel('Done', 'done');
postActions.style.display = 'flex';
statusBar.className = 'status-bar';
} catch (err) {
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');
postActions.style.display = 'flex';
}
}
function toggleRecording() {
if (state === 'idle' || state === 'done') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
}
// --- Save to rSpace ---
async function saveToRSpace() {
saveBtn.disabled = true;
showStatusBar('Saving to rSpace...', 'loading');
const token = await getToken();
const settings = await getSettings();
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
? `<p>${finalTranscript.replace(/\n/g, '</p><p>')}</p>`
: '<p><em>Voice recording (no transcript)</em></p>',
type: 'AUDIO',
mimeType: uploadedMimeType || 'audio/webm',
fileUrl: uploadedFileUrl,
fileSize: uploadedFileSize,
duration: duration,
tags: ['voice'],
};
const notebookId = notebookSelect.value;
if (notebookId) body.notebook_id = notebookId;
try {
const res = await fetch(`${apiBase(settings)}/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 rSpace!', 'success');
chrome.runtime.sendMessage({
type: 'notify',
title: 'Voice Note Saved',
message: `${formatTime(duration)} recording saved to rSpace`,
});
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 = '';
liveTranscript = '';
uploadedFileUrl = '';
uploadedMimeType = '';
uploadedFileSize = 0;
duration = 0;
stopLiveTranscription();
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');
hideParakeetProgress();
statusBar.className = 'status-bar';
}
// --- Keyboard shortcuts ---
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && document.activeElement !== transcriptText) {
e.preventDefault();
toggleRecording();
}
if (e.code === 'Escape') {
window.close();
}
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
e.preventDefault();
saveToRSpace();
}
});
transcriptText.addEventListener('focus', () => {
const ph = transcriptText.querySelector('.placeholder');
if (ph) transcriptText.textContent = '';
});
// --- Event listeners ---
recBtn.addEventListener('click', toggleRecording);
saveBtn.addEventListener('click', saveToRSpace);
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);