feat: add live transcription demo to landing page and demo page

Interactive Web Speech API transcription widget on the homepage
("Try Live Transcription") and demo page. Zero-download, no auth
needed — works instantly in Chrome/Edge/Safari. Updated feature
cards to highlight live transcribe, audio/video, and offline privacy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 10:05:48 +00:00
parent fd51c06304
commit e3be857703
2 changed files with 314 additions and 2 deletions

View File

@ -3,6 +3,7 @@
import Link from 'next/link'
import { useState, useMemo, useCallback } from 'react'
import { useDemoSync, type DemoShape } from '@/lib/demo-sync'
import { TranscriptionDemo } from '@/components/TranscriptionDemo'
/* --- Types -------------------------------------------------------------- */
@ -652,8 +653,9 @@ export default function DemoContent() {
{notebook?.description || 'A collaborative knowledge base for your team'}
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>Live transcription</span>
<span>Audio & video</span>
<span>Organized notebooks</span>
<span>Flexible tagging</span>
<span>Canvas sync</span>
<span>Real-time collaboration</span>
</div>
@ -786,11 +788,27 @@ export default function DemoContent() {
</div>
</section>
{/* Live transcription demo */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-3">Live Voice Transcription</h2>
<p className="text-sm text-slate-400 max-w-lg mx-auto">
Speak and see your words appear in real time. rNotes transcribes audio and video live or from files with offline privacy via NVIDIA Parakeet.
</p>
</div>
<TranscriptionDemo />
</section>
{/* Features showcase */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<h2 className="text-2xl font-bold text-white text-center mb-8">Everything you need to capture knowledge</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-4">
{[
{
icon: 'voice',
title: 'Live Transcription',
desc: 'Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.',
},
{
icon: 'rich-edit',
title: 'Rich Editing',
@ -817,6 +835,11 @@ export default function DemoContent() {
className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-5"
>
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center mb-3">
{feature.icon === 'voice' && (
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
)}
{feature.icon === 'rich-edit' && (
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />

View File

@ -0,0 +1,289 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
/* Web Speech API types — not in default TS lib */
interface SpeechRecognitionResult {
readonly isFinal: boolean;
readonly length: number;
item(index: number): { transcript: string; confidence: number };
[index: number]: { transcript: string; confidence: number };
}
interface SpeechRecognitionResultList {
readonly length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
}
interface ISpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
}
type SpeechRecognitionCtor = new () => ISpeechRecognition;
type DemoStatus = 'idle' | 'listening' | 'unsupported';
interface TranscriptLine {
id: number;
text: string;
final: boolean;
}
export function TranscriptionDemo() {
const [status, setStatus] = useState<DemoStatus>('idle');
const [lines, setLines] = useState<TranscriptLine[]>([]);
const [interim, setInterim] = useState('');
const [elapsed, setElapsed] = useState(0);
const recognitionRef = useRef<ISpeechRecognition | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef(0);
const scrollRef = useRef<HTMLDivElement>(null);
const lineIdRef = useRef(0);
const supported =
typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch {}
}
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines, interim]);
const start = useCallback(() => {
if (!supported) {
setStatus('unsupported');
return;
}
const Ctor: SpeechRecognitionCtor | undefined =
(window as unknown as Record<string, SpeechRecognitionCtor>).SpeechRecognition ||
(window as unknown as Record<string, SpeechRecognitionCtor>).webkitSpeechRecognition;
if (!Ctor) {
setStatus('unsupported');
return;
}
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interimText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
const id = ++lineIdRef.current;
setLines((prev) => [...prev, { id, text: result[0].transcript.trim(), final: true }]);
interimText = '';
} else {
interimText += result[0].transcript;
}
}
setInterim(interimText);
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error !== 'aborted') {
console.warn('Speech recognition error:', event.error);
}
};
recognition.onend = () => {
if (recognitionRef.current === recognition) {
try { recognition.start(); } catch {}
}
};
recognitionRef.current = recognition;
setLines([]);
setInterim('');
lineIdRef.current = 0;
setElapsed(0);
startTimeRef.current = Date.now();
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
recognition.start();
setStatus('listening');
}, [supported, status]);
const stop = useCallback(() => {
if (recognitionRef.current) {
const ref = recognitionRef.current;
recognitionRef.current = null;
try { ref.stop(); } catch {}
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setInterim('');
setStatus('idle');
}, []);
const reset = useCallback(() => {
stop();
setLines([]);
setElapsed(0);
}, [stop]);
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}`;
};
return (
<div className="w-full max-w-2xl mx-auto">
<div className="rounded-2xl border border-slate-700/50 bg-slate-800/50 overflow-hidden">
{/* Header bar */}
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50 bg-slate-800/30">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<span className="text-sm font-medium text-slate-300">Live Transcription</span>
{status === 'listening' && (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
LIVE
</span>
)}
</div>
{status === 'listening' && (
<span className="text-xs font-mono text-slate-400">{formatTime(elapsed)}</span>
)}
</div>
{/* Transcript area */}
<div
ref={scrollRef}
className="min-h-[120px] max-h-[200px] overflow-y-auto px-5 py-4"
>
{status === 'idle' && lines.length === 0 && (
<div className="flex flex-col items-center justify-center h-[120px] text-center">
<svg className="w-10 h-10 text-slate-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<p className="text-sm text-slate-500">
Tap the mic to start live transcription
</p>
<p className="text-xs text-slate-600 mt-1">
Works in your browser no download needed
</p>
</div>
)}
{status === 'unsupported' && (
<div className="flex flex-col items-center justify-center h-[120px] text-center">
<p className="text-sm text-slate-400">
Speech recognition requires Chrome, Edge, or Safari.
</p>
<p className="text-xs text-slate-500 mt-1">
rNotes also supports offline transcription with Parakeet.js (NVIDIA) for full privacy.
</p>
</div>
)}
{lines.length > 0 && (
<div className="space-y-2">
{lines.map((line) => (
<div
key={line.id}
className="text-sm text-slate-200 px-3 py-2 bg-slate-900/50 rounded-lg border-l-2 border-amber-500/40"
>
{line.text}
</div>
))}
</div>
)}
{interim && (
<div className="text-sm text-slate-400 italic px-3 py-2 mt-2">
{interim}
</div>
)}
{status === 'idle' && lines.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-700/30 flex items-center justify-between">
<span className="text-xs text-slate-500">
{lines.length} segment{lines.length !== 1 ? 's' : ''} transcribed
</span>
<button
onClick={reset}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Clear
</button>
</div>
)}
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-3 px-5 py-4 border-t border-slate-700/50">
{status === 'idle' ? (
<button
onClick={start}
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-black font-medium text-sm rounded-full transition-all shadow-lg shadow-amber-900/20"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
Start Transcribing
</button>
) : status === 'listening' ? (
<button
onClick={stop}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-700 hover:bg-slate-600 text-white font-medium text-sm rounded-full transition-colors border border-red-500/50"
>
<div className="w-3 h-3 rounded-sm bg-red-500" />
Stop
</button>
) : null}
</div>
{/* Capability badges */}
<div className="flex flex-wrap items-center justify-center gap-2 px-5 pb-4 text-[11px] text-slate-500">
<span className="px-2 py-0.5 rounded-full bg-slate-700/50 border border-slate-600/30">
Live streaming
</span>
<span className="px-2 py-0.5 rounded-full bg-slate-700/50 border border-slate-600/30">
Audio file upload
</span>
<span className="px-2 py-0.5 rounded-full bg-slate-700/50 border border-slate-600/30">
Video transcription
</span>
<span className="px-2 py-0.5 rounded-full bg-slate-700/50 border border-slate-600/30">
Offline (Parakeet.js)
</span>
</div>
</div>
</div>
);
}