// ============================================================ // Recorder — shared mic-capture component for every note page. // Faithful React port of public/js/liveEncounter.js (and the // equivalent dictation/SOAP recording wrappers). Mirror behavior: // • Mic permission via getUserMedia (mono / 16 kHz / EC+NS) // • Live preview via Web Speech API (when enabled by user) // • Pause / Resume native MediaRecorder where supported // • On Stop → upload blob to /api/transcribe // • If transcription unavailable, fall back to the live preview text // • Failed uploads → /api/audio-backups (or IndexedDB) for retry // // The recorder is dumb about persistence — the parent note page // owns the transcript text and uses encounter-persistence.ts to // save/resume across sign-outs. // ============================================================ import { useEffect, useRef, useState } from 'react'; import { AudioRecorder } from '@/lib/recorder'; import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe'; import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech'; interface Props { module: string; // 'encounter' | 'dictation' | 'soap' | … // Called when transcription completes. The text replaces (or merges with) // whatever the parent currently has in the transcript field. onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void; // Called continuously with the live (interim) preview while recording. // Parents can show a faded "interim" string concatenated to the // confirmed transcript for instant visual feedback. onInterim?: (text: string) => void; // Called when transcription fails (so the parent can decide what to // do — typically appending the live preview text instead). onError?: (msg: string) => void; disabled?: boolean; } const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors'; export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) { const recorderRef = useRef(null); const speechRef = useRef(null); const finalTextRef = useRef(''); const intervalRef = useRef(null); const startTimeRef = useRef(0); const pauseAccumRef = useRef(0); const pauseStartRef = useRef(0); const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle'); const [seconds, setSeconds] = useState(0); useEffect(() => { if (isTranscribeAvailable() === null) checkTranscribeStatus(); }, []); // Stop everything cleanly on unmount (page navigation while recording). useEffect(() => { return () => { if (intervalRef.current) window.clearInterval(intervalRef.current); if (speechRef.current) speechRef.current.stop(); if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ }); }; }, []); function tickStart() { if (intervalRef.current) window.clearInterval(intervalRef.current); intervalRef.current = window.setInterval(() => { const now = Date.now(); const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000); setSeconds(elapsed); }, 1000); } function tickStop() { if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; } } async function start() { if (state !== 'idle') return; finalTextRef.current = ''; pauseAccumRef.current = 0; setSeconds(0); try { const rec = new AudioRecorder(); await rec.start(); recorderRef.current = rec; startTimeRef.current = Date.now(); tickStart(); setState('recording'); // Live preview via Web Speech (only if user enabled it in Settings). if (isSpeechRecognitionEnabled()) { const handle = createSpeechSession({ onFinal: (chunk) => { const deduped = deduplicateFinal(chunk, finalTextRef.current); finalTextRef.current += deduped; onInterim?.(finalTextRef.current); }, onInterim: (interim) => onInterim?.(finalTextRef.current + interim), onError: () => { /* swallow */ }, }); speechRef.current = handle; handle?.start(); } } catch { onError?.('Microphone permission denied'); setState('idle'); } } function pause() { if (state !== 'recording') return; recorderRef.current?.pause(); speechRef.current?.stop(); pauseStartRef.current = Date.now(); tickStop(); setState('paused'); } function resume() { if (state !== 'paused') return; recorderRef.current?.resume(); pauseAccumRef.current += Date.now() - pauseStartRef.current; tickStart(); if (isSpeechRecognitionEnabled() && !speechRef.current) { const handle = createSpeechSession({ onFinal: (chunk) => { const deduped = deduplicateFinal(chunk, finalTextRef.current); finalTextRef.current += deduped; onInterim?.(finalTextRef.current); }, onInterim: (interim) => onInterim?.(finalTextRef.current + interim), }); speechRef.current = handle; } speechRef.current?.start(); setState('recording'); } async function stop() { if (state !== 'recording' && state !== 'paused') return; const liveText = finalTextRef.current.trim(); speechRef.current?.stop(); speechRef.current = null; tickStop(); const dur = seconds; setState('transcribing'); try { const blob = await recorderRef.current!.stop(); recorderRef.current = null; if (!blob || blob.size === 0) { // Recording produced nothing — fall back to live preview if any. if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); setState('idle'); return; } // Server-side too-large guard mirrors the vanilla 24 MB cap. if (blob.size > 24 * 1024 * 1024) { onError?.('Recording too large for AI transcription — using live transcript'); if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); setState('idle'); return; } const result = await transcribeAudio(blob, module); if (result.success && result.text) { onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false }); } else if (result.noProvider) { if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); else onError?.('No transcription API configured'); } else { if (liveText) onTranscript(liveText, { durationSec: dur, appended: true }); else onError?.(result.error || 'Transcription failed'); } } catch (e) { onError?.((e as Error).message); } finally { setState('idle'); } } const mm = String(Math.floor(seconds / 60)).padStart(2, '0'); const ss = String(seconds % 60).padStart(2, '0'); return (
{state === 'idle' && ( )} {(state === 'recording' || state === 'paused') && ( <> {state === 'paused' ? 'paused ' : ''}{mm}:{ss} )} {state === 'transcribing' && ( ⌛ Transcribing… )}
); }