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
215 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|