'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import { authFetch } from '@/lib/authFetch'; interface Segment { id: number; text: string; start: number; end: number; } interface VoiceRecorderResult { fileUrl: string; mimeType: string; fileSize: number; duration: number; transcript: string; } interface VoiceRecorderProps { onResult: (result: VoiceRecorderResult) => void; className?: string; } const VOICE_WS_URL = process.env.NEXT_PUBLIC_VOICE_WS_URL || 'wss://voice.jeffemmett.com'; export function VoiceRecorder({ onResult, className }: VoiceRecorderProps) { const [status, setStatus] = useState<'idle' | 'recording' | 'processing'>( 'idle' ); const [elapsed, setElapsed] = useState(0); const [segments, setSegments] = useState([]); const [isListening, setIsListening] = useState(false); const [error, setError] = useState(null); const [audioUrl, setAudioUrl] = useState(null); const [streaming, setStreaming] = useState(false); const mediaRecorderRef = useRef(null); const audioContextRef = useRef(null); const workletNodeRef = useRef(null); const sourceNodeRef = useRef(null); const wsRef = useRef(null); const chunksRef = useRef([]); const segmentsRef = useRef([]); const timerRef = useRef | null>(null); const startTimeRef = useRef(0); const transcriptScrollRef = useRef(null); useEffect(() => { return () => { if (timerRef.current) clearInterval(timerRef.current); if (audioUrl) URL.revokeObjectURL(audioUrl); }; }, [audioUrl]); // Auto-scroll transcript to bottom when new segments arrive useEffect(() => { if (transcriptScrollRef.current) { transcriptScrollRef.current.scrollTop = transcriptScrollRef.current.scrollHeight; } }, [segments]); const addSegment = useCallback((seg: Segment) => { segmentsRef.current = [...segmentsRef.current, seg]; setSegments([...segmentsRef.current]); }, []); const cleanup = 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; } }, []); const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60) .toString() .padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; const startRecording = useCallback(async () => { setError(null); setSegments([]); segmentsRef.current = []; setIsListening(false); setStreaming(false); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Start MediaRecorder for the audio file const mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm', }); chunksRef.current = []; mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mediaRecorder.start(1000); mediaRecorderRef.current = mediaRecorder; // Try to set up WebSocket streaming for live transcription 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('WebSocket connection timeout')); }, 5000); ws.onopen = () => { clearTimeout(timeout); resolve(); }; ws.onerror = () => { clearTimeout(timeout); reject(new Error('WebSocket connection failed')); }; }); // WebSocket message handler ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'listening') { setIsListening(true); setTimeout(() => setIsListening(false), 600); } else if (data.type === 'segment') { addSegment({ id: data.id, text: data.text, start: data.start, end: data.end, }); } } catch { // Ignore parse errors } }; // Set up AudioContext at 16kHz and AudioWorklet for PCM16 streaming 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); // Don't connect to destination — we don't want to hear ourselves setStreaming(true); } catch (wsErr) { console.warn( 'WebSocket streaming unavailable, will batch transcribe:', wsErr ); setStreaming(false); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } } // Start timer startTimeRef.current = Date.now(); setStatus('recording'); setElapsed(0); timerRef.current = setInterval(() => { setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); }, 1000); } catch (err) { setError( err instanceof Error ? err.message : 'Microphone access denied' ); } }, [addSegment]); const stopRecording = useCallback(async () => { const mediaRecorder = mediaRecorderRef.current; if (!mediaRecorder || mediaRecorder.state === 'inactive') return; if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } const duration = Math.floor( (Date.now() - startTimeRef.current) / 1000 ); setStatus('processing'); // Stop AudioWorklet streaming if (workletNodeRef.current) { workletNodeRef.current.disconnect(); workletNodeRef.current = null; } // Send "end" to WebSocket and wait for final segments 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') { addSegment({ id: data.id, text: data.text, start: data.start, end: data.end, }); } if (data.type === 'done') { clearTimeout(timeout); ws.removeEventListener('message', handler); resolve(data.fullText || ''); } } catch { // Ignore } }; ws.addEventListener('message', handler); ws.send(JSON.stringify({ type: 'end' })); }); } catch { // Timeout or error — use accumulated segments } } // Close WebSocket and AudioContext cleanup(); // Stop MediaRecorder and collect the audio blob const blob = await new Promise((resolve) => { mediaRecorder.onstop = () => { mediaRecorder.stream.getTracks().forEach((t) => t.stop()); resolve( new Blob(chunksRef.current, { type: mediaRecorder.mimeType }) ); }; mediaRecorder.stop(); }); const previewUrl = URL.createObjectURL(blob); setAudioUrl(previewUrl); try { // Upload audio file const uploadForm = new FormData(); uploadForm.append('file', blob, 'recording.webm'); const uploadRes = await authFetch('/api/uploads', { method: 'POST', body: uploadForm, }); if (!uploadRes.ok) { const data = await uploadRes.json(); throw new Error(data.error || 'Upload failed'); } const uploadResult = await uploadRes.json(); // Determine transcript: prefer WebSocket fullText, then assembled segments, then batch let transcript = wsFullText; if (!transcript && segmentsRef.current.length > 0) { transcript = segmentsRef.current.map((s) => s.text).join(' '); } if (!transcript) { // Fallback: batch transcription via API proxy try { const transcribeForm = new FormData(); transcribeForm.append('audio', blob, 'recording.webm'); const transcribeRes = await authFetch('/api/voice/transcribe', { method: 'POST', body: transcribeForm, }); if (transcribeRes.ok) { const transcribeResult = await transcribeRes.json(); transcript = transcribeResult.text || ''; } } catch { console.warn('Batch transcription also failed'); } } onResult({ fileUrl: uploadResult.url, mimeType: uploadResult.mimeType, fileSize: uploadResult.size, duration, transcript, }); } catch (err) { setError(err instanceof Error ? err.message : 'Processing failed'); } finally { setStatus('idle'); } }, [onResult, addSegment, cleanup]); const discard = useCallback(() => { if (audioUrl) { URL.revokeObjectURL(audioUrl); setAudioUrl(null); } setSegments([]); segmentsRef.current = []; setElapsed(0); setError(null); setStatus('idle'); }, [audioUrl]); return (
{/* Recording controls */}
{status === 'idle' && !audioUrl && ( <>

Tap to start recording

)} {status === 'recording' && ( <>
{formatTime(elapsed)} {streaming && ( LIVE )}

Tap to stop

)} {status === 'processing' && (

Finalizing transcription...

)} {audioUrl && status === 'idle' && (
)}
{/* Live transcript segments */} {segments.length > 0 && (
Live Transcript
{segments.map((seg) => (
{seg.text}
))}
)} {error && (

{error}

)}
); }