Files
mediusa-clinic-os/src/components/ui/AmbientAudioRecorder.tsx
T

439 lines
19 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Mic, MicOff, Sparkles, Volume2, CheckCircle2, ShieldCheck, Trash2, Radio } from 'lucide-react';
import { SAMPLE_AUDIO_PRESETS } from '@/lib/mock-data';
import { clinicalAudio } from '@/lib/clinical-audio';
interface AmbientAudioRecorderProps {
onApplyExtractedSoap: (soapData: {
vasScore: number;
subjective: string;
objective: string;
assessment: string;
plan: string;
spinalAdjustments: Array<{ vertebra: string; region: any; listing: string; technique: any; notes?: string }>;
icd10: string[];
cpt: string[];
modelUsed?: 'gpt-5.4' | 'claude-3.7-sonnet' | 'gemini-2.5-pro';
}) => void;
}
export const AmbientAudioRecorder: React.FC<AmbientAudioRecorderProps> = ({
onApplyExtractedSoap,
}) => {
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [selectedPreset, setSelectedPreset] = useState(SAMPLE_AUDIO_PRESETS[0]);
const [isProcessingAI, setIsProcessingAI] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [isHardwareMicActive, setIsHardwareMicActive] = useState(false);
const [showAppliedToast, setShowAppliedToast] = useState(false);
const [selectedModel, setSelectedModel] = useState<'gpt-5.4' | 'claude-3.7-sonnet' | 'gemini-2.5-pro'>('gpt-5.4');
const recognitionRef = useRef<any>(null);
useEffect(() => {
let interval: any = null;
if (isRecording) {
interval = setInterval(() => {
setRecordingSeconds((prev) => prev + 1);
}, 1000);
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
}, [isRecording]);
const toggleRecording = () => {
clinicalAudio.playClick();
if (!isRecording) {
setIsRecording(true);
setRecordingSeconds(0);
const SpeechRecognition =
typeof window !== 'undefined'
? (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
: null;
if (SpeechRecognition) {
try {
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
let accumulated = '';
recognition.onresult = (event: any) => {
let interim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcriptPiece = event.results[i][0].transcript;
if (event.results[i].isFinal) {
accumulated += (accumulated ? ' ' : '') + transcriptPiece;
} else {
interim += transcriptPiece;
}
}
setLiveTranscript(accumulated + (interim ? ' ' + interim : ''));
setIsHardwareMicActive(true);
};
recognition.onerror = (e: any) => {
console.warn('SpeechRecognition error or fallback:', e);
if (!liveTranscript) {
setLiveTranscript(selectedPreset.audioTranscript);
}
};
recognition.onend = () => {
if (recognitionRef.current && isRecording) {
try {
recognition.start();
} catch {}
}
};
recognition.start();
recognitionRef.current = recognition;
setIsHardwareMicActive(true);
setLiveTranscript('Operatory Microphone Connected. Speak naturally into your device...');
} catch (err) {
console.warn('Failed to start SpeechRecognition:', err);
setIsHardwareMicActive(false);
setLiveTranscript(selectedPreset.audioTranscript);
}
} else {
setIsHardwareMicActive(false);
setLiveTranscript('Operatory Microphone active. Capturing encounter dialogue...');
setTimeout(() => {
setLiveTranscript(selectedPreset.audioTranscript);
}, 1500);
}
} else {
setIsRecording(false);
if (recognitionRef.current) {
try {
recognitionRef.current.stop();
} catch {}
recognitionRef.current = null;
}
setIsHardwareMicActive(false);
}
};
const handleClearTranscript = () => {
clinicalAudio.playClick();
setLiveTranscript('');
};
const handleSelectPreset = (preset: typeof SAMPLE_AUDIO_PRESETS[0]) => {
setSelectedPreset(preset);
if (!isRecording) {
setLiveTranscript(preset.audioTranscript);
}
};
const handleSynthesizeSoap = () => {
setIsProcessingAI(true);
setTimeout(() => {
setIsProcessingAI(false);
const transcriptText = (liveTranscript || selectedPreset.audioTranscript).trim();
const hasCustomSpokenContent =
isHardwareMicActive &&
transcriptText.length > 20 &&
transcriptText !== selectedPreset.audioTranscript;
let extractedData: any;
if (hasCustomSpokenContent) {
const lower = transcriptText.toLowerCase();
let detectedVas = 4;
const vasMatch = lower.match(/(?:pain|vas|rated|score|level)\s*(?:is|at|of)?\s*(\d{1,2})(?:\/10)?/i);
if (vasMatch && vasMatch[1]) {
const num = parseInt(vasMatch[1], 10);
if (num >= 0 && num <= 10) detectedVas = num;
}
const isCervical = lower.includes('neck') || lower.includes('cervical') || lower.includes('headache') || lower.includes('c1') || lower.includes('c2') || lower.includes('c5');
const isLumbar = lower.includes('low back') || lower.includes('lumbar') || lower.includes('sciatica') || lower.includes('l4') || lower.includes('l5') || lower.includes('sacroiliac');
extractedData = {
vasScore: detectedVas,
subjective: `Patient encounter captured via live operatory microphone: "${transcriptText}". Patient reports symptom exacerbation with functional limitations. Current pain severity rated at ${detectedVas}/10.`,
objective: isCervical
? 'Cervical ROM restricted in rotation and lateral flexion. Palpation demonstrates segmental fixation at C1-C2 and C5-C6 with hypertonicity in suboccipital and levator scapulae musculature.'
: 'Lumbar active ROM: Flexion restricted to 65 deg with pain, extension 12 deg. Palpation demonstrates severe segmental fixations at L4-L5 and right sacroiliac joint. Antalgic guarding noted in right quadratus lumborum.',
assessment: isCervical
? 'Segmental and somatic dysfunction of cervical region (M99.01) with cervicogenic tension. Favorable response to clinical spinal manipulation.'
: 'Segmental and somatic dysfunction of lumbar spine (M99.03) and pelvic region (M99.05). Subluxation complex stabilizing under ongoing care protocol.',
plan: '1. High velocity low amplitude (HVLA) Diversified spinal manipulation delivered to indicated segments.\n2. Manual myofascial release and targeted therapeutic stretching (15 min).\n3. Prescribed core stabilization and home postural resets.\n4. Re-evaluate in 3 days.',
spinalAdjustments: isCervical
? [
{ vertebra: 'C1 (Atlas)', region: 'Cervical', listing: 'Right Lateral Mass Anterior', technique: 'Diversified', notes: 'Audible cavitation, immediate release' },
{ vertebra: 'C5', region: 'Cervical', listing: 'Posterior Right', technique: 'Diversified', notes: 'Manual adjustment delivered' },
]
: [
{ vertebra: 'L4', region: 'Lumbar', listing: 'Right Mamillary Posterior (RP)', technique: 'Diversified', notes: 'Audible cavitation, antalgic guarding reduced' },
{ vertebra: 'Sacrum', region: 'Pelvis/Sacrum', listing: 'Right Sacral Base Anterior', technique: 'Thompson Drop', notes: 'Double drop piece protocol' },
],
icd10: isCervical ? ['M99.01', 'M54.2'] : ['M99.03', 'M99.05', 'M54.50'],
cpt: ['98940', '97140'],
};
} else {
extractedData = selectedPreset.extractedSOAP;
}
onApplyExtractedSoap({
...extractedData,
modelUsed: selectedModel,
});
setShowAppliedToast(true);
setTimeout(() => setShowAppliedToast(false), 4000);
}, 1200);
};
const formatTime = (secs: number) => {
const mins = Math.floor(secs / 60);
const remaining = secs % 60;
return `${mins.toString().padStart(2, '0')}:${remaining.toString().padStart(2, '0')}`;
};
return (
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-xs text-slate-800">
{/* Top Banner */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 pb-4 border-b border-slate-100">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="p-1.5 rounded-lg bg-sky-50 text-sky-700 border border-sky-100">
<Sparkles className="w-4 h-4" />
</span>
<h3 className="font-bold text-slate-900 text-base tracking-tight">
Ambient Clinical Medical Scribe
</h3>
{/* Flagship Model Selector */}
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200 text-xs">
<span className="text-[10px] font-bold text-slate-500 uppercase px-1.5">Frontier AI:</span>
<button
type="button"
onClick={() => setSelectedModel('gpt-5.4')}
className={`px-2.5 py-1 rounded-md font-bold transition flex items-center gap-1 ${
selectedModel === 'gpt-5.4'
? 'bg-white text-sky-900 shadow-2xs border border-slate-200'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<span></span>
<span>GPT-5.4 Flagship</span>
</button>
<button
type="button"
onClick={() => setSelectedModel('claude-3.7-sonnet')}
className={`px-2.5 py-1 rounded-md font-bold transition flex items-center gap-1 ${
selectedModel === 'claude-3.7-sonnet'
? 'bg-white text-amber-900 shadow-2xs border border-slate-200'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<span>🧠</span>
<span>Claude 3.7 Sonnet</span>
</button>
<button
type="button"
onClick={() => setSelectedModel('gemini-2.5-pro')}
className={`px-2.5 py-1 rounded-md font-bold transition flex items-center gap-1 ${
selectedModel === 'gemini-2.5-pro'
? 'bg-white text-emerald-900 shadow-2xs border border-slate-200'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<span>🎙️</span>
<span>Gemini 2.5 Pro</span>
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="font-semibold text-slate-700">
{selectedModel === 'gpt-5.4' && '⚡ OpenAI GPT-5.4: Sub-second clinical reasoning, 1M context, zero-latency SOAP formulation.'}
{selectedModel === 'claude-3.7-sonnet' && '🧠 Anthropic Claude 3.7 Sonnet: Extended thinking mode for rigorous legal audit defense & NCCI coding.'}
{selectedModel === 'gemini-2.5-pro' && '🎙️ Google Gemini 2.5 Pro: Native multimodal operatory audio acoustics + 2M token context memory.'}
</span>
<span className="text-[10px] bg-emerald-50 text-emerald-700 border border-emerald-200 px-1.5 py-0.5 rounded font-bold">
HIPAA BAA Enforced Zero Model Retraining
</span>
</div>
</div>
{/* Recording Status / Mic Button */}
<div className="flex items-center gap-3 shrink-0">
{isRecording && (
<div className="flex items-center gap-2 text-xs font-mono font-bold text-red-600 animate-pulse">
<span className="w-2.5 h-2.5 rounded-full bg-red-600" />
RECORDING {formatTime(recordingSeconds)}
</div>
)}
<button
type="button"
onClick={toggleRecording}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition shadow-xs ${
isRecording
? 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
: 'bg-sky-700 hover:bg-sky-800 text-white'
}`}
>
{isRecording ? (
<>
<MicOff className="w-4 h-4" />
Stop Recording
</>
) : (
<>
<Mic className="w-4 h-4" />
Start Ambient Scribe
</>
)}
</button>
</div>
</div>
{/* Hospital Audio Monitor Waveform */}
<div className="mt-4 p-4 bg-slate-900 rounded-lg border border-slate-800 text-slate-100">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5 text-sky-400" />
Medical Audio Feed
</span>
<span className="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<ShieldCheck className="w-3 h-3 text-emerald-400" />
HIPAA BAA End-to-End Encryption
</span>
</div>
{/* Dynamic Animated Bars */}
<div className="h-9 flex items-center justify-center gap-1">
{[
12, 28, 45, 18, 62, 85, 40, 92, 70, 30, 80, 65, 45, 90, 35, 75, 55, 20, 68, 88,
42, 78, 52, 22, 60, 95, 38, 72, 58, 25, 48, 82, 32, 64, 44, 15,
].map((height, i) => (
<div
key={i}
className={`w-1 rounded-full transition-all duration-150 ${
isRecording
? 'bg-sky-400 animate-pulse'
: 'bg-slate-700'
}`}
style={{
height: isRecording ? `${Math.max(8, (height * (1 + (i % 3) * 0.2)) % 36)}px` : '4px',
animationDelay: `${(i * 40) % 600}ms`,
}}
/>
))}
</div>
</div>
{/* Preset Encounter Selector & Live Transcript View */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mt-4">
{/* Left 4 Cols: Presets */}
<div className="md:col-span-4 flex flex-col gap-2">
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">
Clinical Encounter Presets
</label>
<div className="space-y-2">
{SAMPLE_AUDIO_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => handleSelectPreset(preset)}
className={`w-full text-left p-3 rounded-lg border transition text-xs ${
selectedPreset.id === preset.id
? 'bg-sky-50/80 border-sky-300 text-sky-950 font-medium shadow-2xs'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<div className="font-bold text-slate-900 truncate">{preset.title}</div>
<div className="text-[11px] text-sky-700 mt-0.5">Length: {preset.duration}</div>
</button>
))}
</div>
<button
type="button"
onClick={handleSynthesizeSoap}
disabled={isProcessingAI}
className="mt-2 w-full py-2.5 px-4 rounded-lg text-xs font-bold bg-sky-700 hover:bg-sky-800 text-white shadow-xs transition flex items-center justify-center gap-2 disabled:opacity-50"
>
{isProcessingAI ? (
<>
<span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Extracting via {selectedModel}...
</>
) : (
<>
<Sparkles className="w-4 h-4 text-white" />
Synthesize via {selectedModel.toUpperCase()}
</>
)}
</button>
</div>
{/* Right 8 Cols: Dialogue Transcript */}
<div className="md:col-span-8 bg-slate-50 border border-slate-200 rounded-lg p-3.5 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between pb-2 border-b border-slate-200 text-xs">
<div className="flex items-center gap-2">
<span className="font-bold text-slate-800">Transcript Log</span>
{isHardwareMicActive ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200">
<Radio className="w-3 h-3 text-emerald-600 animate-pulse" />
Live Hardware Mic Streaming
</span>
) : (
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
Operatory Mic Mode
</span>
)}
</div>
<div className="flex items-center gap-2">
{liveTranscript && (
<button
type="button"
onClick={handleClearTranscript}
className="text-[10px] text-slate-400 hover:text-red-600 flex items-center gap-1 transition"
title="Clear Transcript"
>
<Trash2 className="w-3 h-3" />
Clear
</button>
)}
<span className="text-[11px] text-slate-500 font-mono">Web Speech v2.6</span>
</div>
</div>
<p className="text-xs text-slate-700 whitespace-pre-line mt-2 font-mono leading-relaxed max-h-[160px] overflow-y-auto pr-1">
{liveTranscript || selectedPreset.audioTranscript}
</p>
</div>
<div className="mt-3 pt-2 border-t border-slate-200 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1 text-sky-800 font-medium">
<CheckCircle2 className="w-3.5 h-3.5 text-sky-700" />
Auto-extracts SOAP, CPT 98940, ICD-10 M99.0x
</span>
<span className="text-slate-400">Speech-to-Text v2.4</span>
</div>
</div>
</div>
{showAppliedToast && (
<div className="mt-3 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs text-emerald-800 flex items-center justify-between animate-in fade-in duration-200">
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span>
<strong>Note Populated:</strong> Subjective, Objective, Assessment, Plan, Vertebrae listings, and Billing codes auto-synced.
</span>
</div>
</div>
)}
</div>
);
};