Files
clarity-helper/components/VoiceSession.tsx
T

248 lines
11 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import { Scenario, UserSettings, AnalysisResult } from '../types';
import { GoogleGenAI } from '@google/genai';
import { SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
import { useLiveSession } from '../hooks/useLiveSession';
import { AlertCircle, Settings2, X, BrainCircuit, Zap, Info } from 'lucide-react';
import { Button } from './ui/button';
// Sub-components
import AudioVisualizer from './AudioVisualizer';
import TranscriptPanel from './TranscriptPanel';
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
import ConnectionState from './VoiceSession/ConnectionState';
import AnalyzingState from './VoiceSession/AnalyzingState';
import VoiceHeader from './VoiceSession/VoiceHeader';
import VoiceControls from './VoiceSession/VoiceControls';
import AudioDeviceSelector from './AudioDeviceSelector';
interface VoiceSessionProps {
scenario: Scenario;
onExit: () => void;
voiceSettings: UserSettings;
onUpdateSettings: (settings: UserSettings) => void;
}
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings, onUpdateSettings }) => {
const [showTranscript, setShowTranscript] = useState(false);
const [showDeviceSelector, setShowDeviceSelector] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
const {
status,
setStatus,
isMuted,
toggleMute,
audioLevel,
aiAudioLevel,
isAiSpeaking,
isUserSpeaking,
messages,
liveInsight,
isAnalyzingTurn,
isSilenceDetected,
sessionPromiseRef,
startSession
} = useLiveSession(scenario, voiceSettings);
const activeLevel = isAiSpeaking ? aiAudioLevel : audioLevel;
const handleStart = useCallback(() => {
setHasStarted(true);
startSession();
}, [startSession]);
const handleFinishSession = useCallback(async () => {
setStatus('analyzing');
if (sessionPromiseRef.current) {
sessionPromiseRef.current.then(session => session.close());
}
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n');
const summaryPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT: ${scenario.title} - ${scenario.description}`;
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: summaryPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptText}` }] }]
});
if (result.text) {
setFinalReport(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Analysis failed");
}
} catch (err) {
console.error("Post-session analysis failure", err);
onExit();
}
}, [messages, scenario, setStatus, sessionPromiseRef, onExit]);
if (status === 'connecting') return <ConnectionState />;
if (status === 'analyzing') return <AnalyzingState />;
if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />;
}
return (
<div className="flex flex-col lg:flex-row h-full gap-4 relative overflow-hidden">
{/* Main Interaction Area */}
<div className="flex-grow bg-slate-900 rounded-3xl overflow-hidden flex flex-col relative shadow-2xl border border-white/5">
{/* Animated Background Aura */}
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80%] h-[80%] rounded-full blur-[100px] transition-all duration-1000 ${isAiSpeaking ? 'bg-blue-500 scale-110' : 'bg-teal-500 scale-100'}`}></div>
</div>
<VoiceHeader
title={scenario.title}
onExit={onExit}
onFinish={handleFinishSession}
/>
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
{!hasStarted && status === 'active' ? (
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-700">
<div className="w-24 h-24 bg-teal-500/10 rounded-full flex items-center justify-center mb-8 border border-teal-500/20 relative">
<div className="absolute inset-0 bg-teal-500/20 rounded-full animate-ping"></div>
<BrainCircuit size={40} className="text-teal-400 relative z-10" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Neural Link Established</h3>
<p className="text-slate-400 text-sm mb-8 max-w-xs text-center leading-relaxed">
The simulation is ready. Press start to begin your interaction with <strong>{scenario.characterName}</strong>.
</p>
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 mb-8 flex items-center gap-3 max-w-sm">
<div className="bg-teal-500/20 p-2 rounded-lg">
<Info size={16} className="text-teal-400" />
</div>
<p className="text-[11px] text-slate-300 leading-relaxed">
<strong>Greeting Hint:</strong> Say "Hello {scenario.characterName}" or just wait for them to speak first.
</p>
</div>
<Button
onClick={handleStart}
className="bg-teal-600 hover:bg-teal-500 text-white px-12 py-7 rounded-2xl font-bold transition-all shadow-xl shadow-teal-900/20 uppercase tracking-widest text-sm"
>
<Zap size={18} fill="currentColor" className="mr-3" />
Start Session
</Button>
</div>
) : (
<>
{isSilenceDetected && !isMuted && (
<div className="absolute top-8 left-1/2 -translate-x-1/2 w-full max-w-md animate-in slide-in-from-top-4 duration-500">
<div className="bg-amber-500/90 backdrop-blur-md border border-amber-400 text-white p-4 rounded-2xl shadow-xl flex items-start gap-4">
<div className="bg-white/20 p-2 rounded-xl">
<AlertCircle size={20} />
</div>
<div className="flex-grow">
<h4 className="text-sm font-bold">No Audio Detected</h4>
<p className="text-xs text-white/80 mt-1 leading-relaxed">We can't hear you. Please check your microphone or select a different input device.</p>
<Button
variant="secondary"
size="sm"
onClick={() => setShowDeviceSelector(true)}
className="mt-3 bg-white text-amber-600 px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-amber-50"
>
Change Input Device
</Button>
</div>
</div>
</div>
)}
{/* Speaker Indicator Badge */}
<div className="mb-8 flex flex-col items-center">
<div className={`px-4 py-1.5 rounded-full border transition-all duration-500 flex items-center gap-2 ${isAiSpeaking
? 'bg-blue-500/20 border-blue-400/30 text-blue-300'
: (isUserSpeaking ? 'bg-teal-500/20 border-teal-400/30 text-teal-300' : 'bg-white/5 border-white/10 text-white/40')
}`}>
<div className={`w-2 h-2 rounded-full ${isAiSpeaking ? 'bg-blue-400 animate-pulse' : (isUserSpeaking ? 'bg-teal-400 animate-pulse' : 'bg-white/20')
}`} />
<span className="text-[10px] font-black uppercase tracking-widest">
{isAiSpeaking ? 'AI Speaking' : (isUserSpeaking ? 'You Speaking' : 'Waiting...')}
</span>
</div>
</div>
<AudioVisualizer level={activeLevel} active={true} isAiSpeaking={isAiSpeaking} />
<div className="mt-12 text-center">
<p className={`text-sm transition-all duration-500 font-light ${isAiSpeaking ? 'text-blue-300' : 'text-teal-300'}`}>
{isAiSpeaking ? `Vocalizing Response...` : (isMuted ? 'Microphone Muted' : 'Listening...')}
</p>
<p className="text-[10px] text-white/30 uppercase font-bold tracking-[0.3em] mt-4">Clinical Fidelity Level: 99.8%</p>
</div>
</>
)}
</div>
{/* Device Selector Overlay */}
{showDeviceSelector && (
<div className="absolute inset-0 z-50 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl p-6 w-full max-w-sm shadow-2xl border border-slate-100">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="bg-slate-800 p-2 rounded-xl text-white">
<Settings2 size={18} />
</div>
<h3 className="font-bold text-slate-800">Audio Input</h3>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowDeviceSelector(false)}
className="text-slate-400 hover:bg-slate-100 rounded-xl"
>
<X size={20} />
</Button>
</div>
<AudioDeviceSelector
selectedDeviceId={voiceSettings.preferredAudioDeviceId}
onSelect={(deviceId) => {
onUpdateSettings({ ...voiceSettings, preferredAudioDeviceId: deviceId });
// Don't close immediately so user sees the checkmark
setTimeout(() => setShowDeviceSelector(false), 300);
}}
/>
<p className="text-[10px] text-slate-400 mt-6 leading-relaxed italic">
Changing your input device will restart the current neural link.
</p>
</div>
</div>
)}
<VoiceControls
isMuted={isMuted}
onToggleMute={toggleMute}
showTranscript={showTranscript}
onToggleTranscript={() => setShowTranscript(prev => !prev)}
/>
{showTranscript && <TranscriptPanel messages={messages} onClose={() => setShowTranscript(false)} />}
</div>
{/* Real-time Insights Module */}
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
</div>
);
};
export default React.memo(VoiceSession);