482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
/**
|
|
* <folk-voice-recorder> — Standalone voice recorder web component.
|
|
*
|
|
* Full-page recorder with MediaRecorder, SpeechDictation (live),
|
|
* and three-tier transcription cascade:
|
|
* 1. Server (voice-command-api)
|
|
* 2. Live (Web Speech API captured during recording)
|
|
* 3. Offline (Parakeet TDT 0.6B in-browser)
|
|
*
|
|
* Saves AUDIO notes to rNotes via REST API.
|
|
*/
|
|
|
|
import { SpeechDictation } from '../../../lib/speech-dictation';
|
|
import { transcribeOffline, isModelCached } from '../../../lib/parakeet-offline';
|
|
import type { TranscriptionProgress } from '../../../lib/parakeet-offline';
|
|
import { getAccessToken } from '../../../shared/components/rstack-identity';
|
|
|
|
type RecorderState = 'idle' | 'recording' | 'processing' | 'done';
|
|
|
|
class FolkVoiceRecorder extends HTMLElement {
|
|
private shadow!: ShadowRoot;
|
|
private space = '';
|
|
private state: RecorderState = 'idle';
|
|
private mediaRecorder: MediaRecorder | null = null;
|
|
private audioChunks: Blob[] = [];
|
|
private dictation: SpeechDictation | null = null;
|
|
private liveTranscript = '';
|
|
private finalTranscript = '';
|
|
private recordingStartTime = 0;
|
|
private durationTimer: ReturnType<typeof setInterval> | null = null;
|
|
private elapsedSeconds = 0;
|
|
private audioBlob: Blob | null = null;
|
|
private audioUrl: string | null = null;
|
|
private progressMessage = '';
|
|
private selectedNotebookId = '';
|
|
private notebooks: { id: string; title: string }[] = [];
|
|
private tags = '';
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute('space') || 'demo';
|
|
this.loadNotebooks();
|
|
this.render();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.cleanup();
|
|
}
|
|
|
|
private cleanup() {
|
|
this.stopDurationTimer();
|
|
this.dictation?.destroy();
|
|
this.dictation = null;
|
|
if (this.mediaRecorder?.state === 'recording') {
|
|
this.mediaRecorder.stop();
|
|
}
|
|
this.mediaRecorder = null;
|
|
if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/rnotes/);
|
|
return match ? match[0] : '';
|
|
}
|
|
|
|
private authHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
const headers: Record<string, string> = { ...extra };
|
|
const token = getAccessToken();
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
return headers;
|
|
}
|
|
|
|
private async loadNotebooks() {
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/notebooks`, { headers: this.authHeaders() });
|
|
const data = await res.json();
|
|
this.notebooks = (data.notebooks || []).map((nb: any) => ({ id: nb.id, title: nb.title }));
|
|
if (this.notebooks.length > 0 && !this.selectedNotebookId) {
|
|
this.selectedNotebookId = this.notebooks[0].id;
|
|
}
|
|
this.render();
|
|
} catch { /* fallback: empty list */ }
|
|
}
|
|
|
|
private async startRecording() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
// Determine supported mimeType
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: MediaRecorder.isTypeSupported('audio/webm')
|
|
? 'audio/webm'
|
|
: 'audio/mp4';
|
|
|
|
this.audioChunks = [];
|
|
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
|
|
this.mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) this.audioChunks.push(e.data);
|
|
};
|
|
|
|
this.mediaRecorder.onstop = () => {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
this.audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
|
if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
|
|
this.audioUrl = URL.createObjectURL(this.audioBlob);
|
|
this.processRecording();
|
|
};
|
|
|
|
this.mediaRecorder.start(1000); // 1s timeslice
|
|
|
|
// Start live transcription via Web Speech API
|
|
this.liveTranscript = '';
|
|
if (SpeechDictation.isSupported()) {
|
|
this.dictation = new SpeechDictation({
|
|
onFinal: (text) => { this.liveTranscript += text + ' '; this.render(); },
|
|
onInterim: () => { this.render(); },
|
|
});
|
|
this.dictation.start();
|
|
}
|
|
|
|
// Start timer
|
|
this.recordingStartTime = Date.now();
|
|
this.elapsedSeconds = 0;
|
|
this.durationTimer = setInterval(() => {
|
|
this.elapsedSeconds = Math.floor((Date.now() - this.recordingStartTime) / 1000);
|
|
this.render();
|
|
}, 1000);
|
|
|
|
this.state = 'recording';
|
|
this.render();
|
|
} catch (err) {
|
|
console.error('Failed to start recording:', err);
|
|
}
|
|
}
|
|
|
|
private stopRecording() {
|
|
this.stopDurationTimer();
|
|
this.dictation?.stop();
|
|
if (this.mediaRecorder?.state === 'recording') {
|
|
this.mediaRecorder.stop();
|
|
}
|
|
}
|
|
|
|
private stopDurationTimer() {
|
|
if (this.durationTimer) {
|
|
clearInterval(this.durationTimer);
|
|
this.durationTimer = null;
|
|
}
|
|
}
|
|
|
|
private async processRecording() {
|
|
this.state = 'processing';
|
|
this.progressMessage = 'Processing recording...';
|
|
this.render();
|
|
|
|
// Three-tier transcription cascade
|
|
let transcript = '';
|
|
|
|
// Tier 1: Server transcription
|
|
if (this.audioBlob && this.space !== 'demo') {
|
|
try {
|
|
this.progressMessage = 'Sending to server for transcription...';
|
|
this.render();
|
|
const base = this.getApiBase();
|
|
const formData = new FormData();
|
|
formData.append('file', this.audioBlob, 'recording.webm');
|
|
const res = await fetch(`${base}/api/voice/transcribe`, {
|
|
method: 'POST',
|
|
headers: this.authHeaders(),
|
|
body: formData,
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
transcript = data.text || data.transcript || '';
|
|
}
|
|
} catch { /* fall through to next tier */ }
|
|
}
|
|
|
|
// Tier 2: Live transcript from Web Speech API
|
|
if (!transcript && this.liveTranscript.trim()) {
|
|
transcript = this.liveTranscript.trim();
|
|
}
|
|
|
|
// Tier 3: Offline Parakeet transcription
|
|
if (!transcript && this.audioBlob) {
|
|
try {
|
|
transcript = await transcribeOffline(this.audioBlob, (p: TranscriptionProgress) => {
|
|
this.progressMessage = p.message || 'Processing...';
|
|
this.render();
|
|
});
|
|
} catch {
|
|
this.progressMessage = 'Transcription failed. You can still save the recording.';
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
this.finalTranscript = transcript;
|
|
this.state = 'done';
|
|
this.progressMessage = '';
|
|
this.render();
|
|
}
|
|
|
|
private async saveNote() {
|
|
if (!this.audioBlob || !this.selectedNotebookId) return;
|
|
|
|
const base = this.getApiBase();
|
|
|
|
// Upload audio file
|
|
let fileUrl = '';
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', this.audioBlob, 'recording.webm');
|
|
const uploadRes = await fetch(`${base}/api/uploads`, {
|
|
method: 'POST',
|
|
headers: this.authHeaders(),
|
|
body: formData,
|
|
});
|
|
if (uploadRes.ok) {
|
|
const uploadData = await uploadRes.json();
|
|
fileUrl = uploadData.url;
|
|
}
|
|
} catch { /* continue without file */ }
|
|
|
|
// Create the note
|
|
const tagList = this.tags.split(',').map(t => t.trim()).filter(Boolean);
|
|
tagList.push('voice');
|
|
|
|
try {
|
|
const res = await fetch(`${base}/api/notes`, {
|
|
method: 'POST',
|
|
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
notebook_id: this.selectedNotebookId,
|
|
title: `Voice Note — ${new Date().toLocaleDateString()}`,
|
|
content: this.finalTranscript || '',
|
|
type: 'AUDIO',
|
|
tags: tagList,
|
|
file_url: fileUrl,
|
|
mime_type: this.audioBlob.type,
|
|
duration: this.elapsedSeconds,
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
this.state = 'idle';
|
|
this.finalTranscript = '';
|
|
this.liveTranscript = '';
|
|
this.audioBlob = null;
|
|
if (this.audioUrl) { URL.revokeObjectURL(this.audioUrl); this.audioUrl = null; }
|
|
this.render();
|
|
// Show success briefly
|
|
this.progressMessage = 'Note saved!';
|
|
this.render();
|
|
setTimeout(() => { this.progressMessage = ''; this.render(); }, 2000);
|
|
}
|
|
} catch (err) {
|
|
this.progressMessage = 'Failed to save note';
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private discard() {
|
|
this.cleanup();
|
|
this.state = 'idle';
|
|
this.finalTranscript = '';
|
|
this.liveTranscript = '';
|
|
this.audioBlob = null;
|
|
this.audioUrl = null;
|
|
this.elapsedSeconds = 0;
|
|
this.progressMessage = '';
|
|
this.render();
|
|
}
|
|
|
|
private formatTime(s: number): string {
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
private render() {
|
|
const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
|
|
|
|
let body = '';
|
|
switch (this.state) {
|
|
case 'idle':
|
|
body = `
|
|
<div class="recorder-idle">
|
|
<div class="recorder-icon">
|
|
<svg width="64" height="64" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="5" y="1" width="6" height="9" rx="3"/><path d="M3 7v1a5 5 0 0 0 10 0V7"/>
|
|
<line x1="8" y1="13" x2="8" y2="15"/><line x1="5.5" y1="15" x2="10.5" y2="15"/>
|
|
</svg>
|
|
</div>
|
|
<h2>Voice Recorder</h2>
|
|
<p class="recorder-subtitle">Record voice notes with automatic transcription</p>
|
|
<div class="recorder-config">
|
|
<label>Save to notebook:
|
|
<select id="notebook-select">
|
|
${this.notebooks.map(nb => `<option value="${nb.id}"${nb.id === this.selectedNotebookId ? ' selected' : ''}>${esc(nb.title)}</option>`).join('')}
|
|
</select>
|
|
</label>
|
|
<label>Tags: <input id="tags-input" value="${esc(this.tags)}" placeholder="comma, separated"></label>
|
|
</div>
|
|
<button class="record-btn" id="btn-start">Start Recording</button>
|
|
${isModelCached() ? '<p class="model-status">Offline model cached</p>' : ''}
|
|
</div>`;
|
|
break;
|
|
|
|
case 'recording':
|
|
body = `
|
|
<div class="recorder-recording">
|
|
<div class="recording-pulse"></div>
|
|
<div class="recording-timer">${this.formatTime(this.elapsedSeconds)}</div>
|
|
<p class="recording-status">Recording...</p>
|
|
${this.liveTranscript ? `<div class="live-transcript">${esc(this.liveTranscript)}</div>` : ''}
|
|
<button class="stop-btn" id="btn-stop">Stop</button>
|
|
</div>`;
|
|
break;
|
|
|
|
case 'processing':
|
|
body = `
|
|
<div class="recorder-processing">
|
|
<div class="processing-spinner"></div>
|
|
<p>${esc(this.progressMessage)}</p>
|
|
</div>`;
|
|
break;
|
|
|
|
case 'done':
|
|
body = `
|
|
<div class="recorder-done">
|
|
<h3>Recording Complete</h3>
|
|
${this.audioUrl ? `<audio controls src="${this.audioUrl}" class="result-audio"></audio>` : ''}
|
|
<div class="result-duration">Duration: ${this.formatTime(this.elapsedSeconds)}</div>
|
|
<div class="transcript-section">
|
|
<label>Transcript:</label>
|
|
<textarea id="transcript-edit" class="transcript-textarea">${esc(this.finalTranscript)}</textarea>
|
|
</div>
|
|
<div class="result-actions">
|
|
<button class="save-btn" id="btn-save">Save Note</button>
|
|
<button class="copy-btn" id="btn-copy">Copy Transcript</button>
|
|
<button class="discard-btn" id="btn-discard">Discard</button>
|
|
</div>
|
|
</div>`;
|
|
break;
|
|
}
|
|
|
|
this.shadow.innerHTML = `
|
|
<style>${this.getStyles()}</style>
|
|
<div class="voice-recorder">${body}</div>
|
|
${this.progressMessage && this.state === 'idle' ? `<div class="toast">${esc(this.progressMessage)}</div>` : ''}
|
|
`;
|
|
this.attachListeners();
|
|
}
|
|
|
|
private attachListeners() {
|
|
this.shadow.getElementById('btn-start')?.addEventListener('click', () => this.startRecording());
|
|
this.shadow.getElementById('btn-stop')?.addEventListener('click', () => this.stopRecording());
|
|
this.shadow.getElementById('btn-save')?.addEventListener('click', () => this.saveNote());
|
|
this.shadow.getElementById('btn-discard')?.addEventListener('click', () => this.discard());
|
|
this.shadow.getElementById('btn-copy')?.addEventListener('click', () => {
|
|
const textarea = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
|
|
if (textarea) navigator.clipboard.writeText(textarea.value);
|
|
});
|
|
|
|
const nbSelect = this.shadow.getElementById('notebook-select') as HTMLSelectElement;
|
|
if (nbSelect) nbSelect.addEventListener('change', () => { this.selectedNotebookId = nbSelect.value; });
|
|
|
|
const tagsInput = this.shadow.getElementById('tags-input') as HTMLInputElement;
|
|
if (tagsInput) tagsInput.addEventListener('input', () => { this.tags = tagsInput.value; });
|
|
|
|
const transcriptEdit = this.shadow.getElementById('transcript-edit') as HTMLTextAreaElement;
|
|
if (transcriptEdit) transcriptEdit.addEventListener('input', () => { this.finalTranscript = transcriptEdit.value; });
|
|
}
|
|
|
|
private getStyles(): string {
|
|
return `
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
|
|
* { box-sizing: border-box; }
|
|
|
|
.voice-recorder {
|
|
max-width: 600px; margin: 0 auto; padding: 40px 20px;
|
|
display: flex; flex-direction: column; align-items: center; text-align: center;
|
|
}
|
|
|
|
h2 { font-size: 24px; font-weight: 700; margin: 16px 0 4px; }
|
|
h3 { font-size: 18px; font-weight: 600; margin: 0 0 16px; }
|
|
.recorder-subtitle { color: var(--rs-text-muted); margin: 0 0 24px; }
|
|
|
|
.recorder-icon { color: var(--rs-primary); margin-bottom: 8px; }
|
|
|
|
.recorder-config {
|
|
display: flex; flex-direction: column; gap: 12px; width: 100%;
|
|
max-width: 400px; margin-bottom: 24px; text-align: left;
|
|
}
|
|
.recorder-config label { font-size: 13px; color: var(--rs-text-secondary); display: flex; flex-direction: column; gap: 4px; }
|
|
.recorder-config select, .recorder-config input {
|
|
padding: 8px 12px; border-radius: 6px; border: 1px solid var(--rs-input-border);
|
|
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 14px; font-family: inherit;
|
|
}
|
|
|
|
.record-btn {
|
|
padding: 14px 36px; border-radius: 50px; border: none;
|
|
background: var(--rs-error, #ef4444); color: #fff; font-size: 16px; font-weight: 600;
|
|
cursor: pointer; transition: all 0.2s;
|
|
}
|
|
.record-btn:hover { transform: scale(1.05); filter: brightness(1.1); }
|
|
|
|
.model-status { font-size: 11px; color: var(--rs-text-muted); margin-top: 12px; }
|
|
|
|
/* Recording state */
|
|
.recorder-recording { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
|
.recording-pulse {
|
|
width: 80px; height: 80px; border-radius: 50%;
|
|
background: var(--rs-error, #ef4444); animation: pulse 1.5s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
|
70% { transform: scale(1.05); opacity: 0.8; box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
|
|
100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
|
}
|
|
.recording-timer { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.recording-status { color: var(--rs-error, #ef4444); font-weight: 500; }
|
|
.live-transcript {
|
|
max-width: 500px; padding: 12px 16px; border-radius: 8px;
|
|
background: var(--rs-bg-surface-raised); font-size: 14px; line-height: 1.6;
|
|
text-align: left; max-height: 200px; overflow-y: auto; color: var(--rs-text-secondary);
|
|
}
|
|
.stop-btn {
|
|
padding: 12px 32px; border-radius: 50px; border: none;
|
|
background: var(--rs-text-primary); color: var(--rs-bg-surface); font-size: 15px; font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Processing */
|
|
.recorder-processing { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px; }
|
|
.processing-spinner {
|
|
width: 48px; height: 48px; border: 3px solid var(--rs-border);
|
|
border-top-color: var(--rs-primary); border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* Done */
|
|
.recorder-done { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; }
|
|
.result-audio { width: 100%; max-width: 500px; height: 40px; margin-bottom: 8px; }
|
|
.result-duration { font-size: 13px; color: var(--rs-text-muted); }
|
|
.transcript-section { width: 100%; max-width: 500px; text-align: left; }
|
|
.transcript-section label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.transcript-textarea {
|
|
width: 100%; min-height: 120px; padding: 12px; margin-top: 4px;
|
|
border-radius: 8px; border: 1px solid var(--rs-input-border);
|
|
background: var(--rs-input-bg); color: var(--rs-input-text);
|
|
font-size: 14px; font-family: inherit; line-height: 1.6; resize: vertical;
|
|
}
|
|
.result-actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
.save-btn {
|
|
padding: 10px 24px; border-radius: 8px; border: none;
|
|
background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer;
|
|
}
|
|
.copy-btn, .discard-btn {
|
|
padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer;
|
|
border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary);
|
|
}
|
|
.discard-btn { color: var(--rs-error, #ef4444); border-color: var(--rs-error, #ef4444); }
|
|
|
|
.toast {
|
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
|
padding: 10px 20px; border-radius: 8px; background: var(--rs-primary); color: #fff;
|
|
font-size: 13px; font-weight: 500; z-index: 100;
|
|
}
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-voice-recorder', FolkVoiceRecorder);
|