439 lines
19 KiB
TypeScript
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>
|
|
);
|
|
};
|