pediatric-ai-scribe-v3/client/src/components/Recorder.tsx
Daniel 198002b769 feat(notes): restore audio recording + save/load across all 7 note pages
Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:

Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
  • AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
  • Pause / Resume (native where supported, restart-on-same-stream
    fallback for Safari)
  • Live preview via Web Speech API (opt-in in Settings)
  • On stop → upload to /api/transcribe; fall back to live preview
    if server unavailable or blob > 24 MB
  • Failed uploads → /api/audio-backups (IndexedDB fallback)

Save / Load / New-patient toolbar (all 7 note pages):
  • sessionStorage keys _savedEncId_<type> + _idempKey_<type>
    survive page refresh + sign-out within the same tab
  • Optimistic locking via expected_version (409 → "Someone else
    edited this encounter")
  • Load popover lists saved encounters of matching type only
  • Draft #N chip shows current session-bound row

Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).

New components:
  client/src/components/Recorder.tsx
  client/src/components/EncounterToolbar.tsx

New libraries:
  client/src/lib/recorder.ts            — AudioRecorder class
  client/src/lib/transcribe.ts          — /api/transcribe + audio backup
  client/src/lib/web-speech.ts          — webkit speech preview + dedupe
  client/src/lib/encounter-persistence.ts — save/load/version tracking
2026-04-24 05:24:09 +02:00

215 lines
8.5 KiB
TypeScript

// ============================================================
// 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<AudioRecorder | null>(null);
const speechRef = useRef<SpeechHandle | null>(null);
const finalTextRef = useRef('');
const intervalRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const pauseAccumRef = useRef<number>(0);
const pauseStartRef = useRef<number>(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 (
<div className="flex flex-wrap items-center gap-2" data-testid={'recorder-' + module}>
{state === 'idle' && (
<button type="button" onClick={start} disabled={disabled}
className={btnRecord + ' bg-destructive text-white border-destructive hover:bg-red-700'}
data-testid="recorder-start">
🎙 Start recording
</button>
)}
{(state === 'recording' || state === 'paused') && (
<>
<button type="button" onClick={state === 'recording' ? pause : resume}
className={btnRecord + ' bg-amber-500 text-white border-amber-500 hover:bg-amber-600'}
data-testid="recorder-pause">
{state === 'recording' ? '⏸ Pause' : '▶ Resume'}
</button>
<button type="button" onClick={stop}
className={btnRecord + ' bg-slate-800 text-white border-slate-800 hover:bg-slate-900'}
data-testid="recorder-stop">
Stop
</button>
<span className="inline-flex items-center gap-1.5 text-sm text-destructive font-mono"
data-testid="recorder-timer">
<span className={state === 'recording' ? 'animate-pulse' : 'opacity-50'}></span>
{state === 'paused' ? 'paused ' : ''}{mm}:{ss}
</span>
</>
)}
{state === 'transcribing' && (
<span className="text-sm text-muted-foreground" data-testid="recorder-transcribing">
Transcribing
</span>
)}
</div>
);
}