feat(voice-session): Redesign voice session UI and functionality

Refactor the VoiceSession component to improve its UI and streamline its functionality. This includes updating imports, renaming and reordering elements for better clarity, and introducing new UI elements like a dedicated phone off button. Additionally, the AudioVisualizer component has been updated to visually differentiate between user and AI speech. This change aims to enhance the user experience and improve the modularity of the voice session interface.
This commit is contained in:
John Remillard committed 2025-12-17 13:39:29 -05:00
1 parent a7a8c661b7
commit e92c489db5
2 files changed
+268 -223

No files matched your search

+4 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
interface AudioVisualizerProps {
level: number;
active: boolean;
isAiSpeaking?: boolean;
}
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({ level, active }) => {
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({ level, active, isAiSpeaking = false }) => {
const barCount = 19;
return (
<div className="flex items-center justify-center gap-1.5 h-24 w-full px-4">
@@ -21,7 +23,7 @@ const AudioVisualizer: React.FC<AudioVisualizerProps> = ({ level, active }) => {
key={i}
className={`w-1.5 rounded-full transition-all duration-100 ease-out border-b border-white/5 ${
active
? 'bg-teal-400 shadow-[0_0_15px_rgba(45,212,191,0.6)]'
? (isAiSpeaking ? 'bg-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.6)]' : 'bg-teal-400 shadow-[0_0_15px_rgba(45,212,191,0.6)]')
: 'bg-slate-700/50'
}`}
style={{
+264 -221
View File
@@ -1,17 +1,16 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Scenario, AnalysisResult, UserSettings } from '../types';
import { GoogleGenAI, LiveServerMessage, Modality, Chat } from "@google/genai";
import { Mic, MicOff, X, Radio, AlertCircle, Headphones, BrainCircuit, LayoutPanelLeft, Play, Info, CheckCircle2, MessageSquare, Sparkles, Activity } from 'lucide-react';
import { arrayBufferToBase64, base64ToUint8Array, decodeAudioData, floatTo16BitPCM } from '../services/audioUtils';
import { CBT_ANALYSIS_SCHEMA, SYSTEM_SUMMARY_ANALYSIS_PROMPT, SYSTEM_ANALYSIS_PROMPT } from '../constants';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Scenario, UserSettings, Message, AnalysisResult } from '../types';
import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
import { Mic, MicOff, PhoneOff, MessageSquare, BrainCircuit, Flag, Loader2 } from 'lucide-react';
import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT, CBT_ANALYSIS_SCHEMA } from '../constants';
import { floatTo16BitPCM, arrayBufferToBase64, base64ToUint8Array, decodeAudioData } from '../services/audioUtils';
// Sub-components
import AudioVisualizer from './AudioVisualizer';
import TranscriptPanel from './TranscriptPanel';
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
import Tooltip from './Tooltip';
import TranscriptPanel from './TranscriptPanel';
interface VoiceSessionProps {
scenario: Scenario;
@@ -19,287 +18,331 @@ interface VoiceSessionProps {
voiceSettings: UserSettings;
}
interface DisplayMessage {
id: string;
role: 'user' | 'model';
text: string;
// Manual encoding helper as per guidelines
function encode(bytes: Uint8Array) {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Manual decoding helper as per guidelines
function decode(base64: string) {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit, voiceSettings }) => {
const [sessionPhase, setSessionPhase] = useState<'briefing' | 'session' | 'analyzing' | 'finished'>('briefing');
const [status, setStatus] = useState<'disconnected' | 'connecting' | 'active' | 'error'>('disconnected');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [finalAnalysis, setFinalAnalysis] = useState<AnalysisResult | null>(null);
const [showLiveInsights, setShowLiveInsights] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [showTranscript, setShowTranscript] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [status, setStatus] = useState<'connecting' | 'active' | 'analyzing' | 'finished'>('connecting');
const [audioLevel, setAudioLevel] = useState(0);
const [isAiSpeaking, setIsAiSpeaking] = useState(false);
const [liveAnalysis, setLiveAnalysis] = useState<AnalysisResult | null>(null);
// Analysis States
const [liveInsight, setLiveInsight] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [liveMessages, setLiveMessages] = useState<DisplayMessage[]>([]);
const [finalReport, setFinalReport] = useState<AnalysisResult | null>(null);
const inputContextRef = useRef<AudioContext | null>(null);
const outputContextRef = useRef<AudioContext | null>(null);
// Refs for Audio & Live API
const sessionPromiseRef = useRef<Promise<any> | null>(null);
const inputAudioCtxRef = useRef<AudioContext | null>(null);
const outputAudioCtxRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const sourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const audioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const streamRef = useRef<MediaStream | null>(null);
const analysisChatRef = useRef<any>(null);
const transcriptRef = useRef<string>("");
const currentTurnUserText = useRef<string>("");
const currentTurnPartnerText = useRef<string>("");
// Transcription buffers
const currentInputTranscription = useRef('');
const currentOutputTranscription = useRef('');
const sessionRef = useRef<any>(null);
const analysisChatRef = useRef<Chat | null>(null);
const [volumeLevel, setVolumeLevel] = useState(0);
useEffect(() => {
return () => {
sourcesRef.current.forEach(source => { try { source.stop(); } catch(e) {} });
};
const stopAllAudio = useCallback(() => {
audioSourcesRef.current.forEach(source => {
try { source.stop(); } catch (e) {}
});
audioSourcesRef.current.clear();
nextStartTimeRef.current = 0;
}, []);
const disconnectSession = useCallback(async () => {
sourcesRef.current.forEach(source => { try { source.stop(); } catch(e) {} });
sourcesRef.current.clear();
try {
if (inputContextRef.current && inputContextRef.current.state !== 'closed') await inputContextRef.current.close();
if (outputContextRef.current && outputContextRef.current.state !== 'closed') await outputContextRef.current.close();
} catch (e) { console.error("Error closing contexts", e); }
inputContextRef.current = null;
outputContextRef.current = null;
if (sessionRef.current) {
try {
const session = await sessionRef.current;
if (session && typeof session.close === 'function') session.close();
} catch (e) { console.error("Error closing session:", e); }
sessionRef.current = null;
const handleFinishSession = async () => {
setStatus('analyzing');
// Close live session
if (sessionPromiseRef.current) {
sessionPromiseRef.current.then(session => session.close());
}
}, []);
const handleTerminateAndAnalyze = async () => {
await disconnectSession();
if (transcriptRef.current.length < 10) return onExit();
setSessionPhase('analyzing');
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: `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}\n\nSCENARIO: ${scenario.title}\nCBT: ${scenario.cbtFocus.join(', ')}`,
systemInstruction: summaryPrompt,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `TRANSCRIPT:\n${transcriptRef.current}` }] }]
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptText}` }] }]
});
if (result.text) {
setFinalAnalysis(JSON.parse(result.text));
setSessionPhase('finished');
} else { throw new Error("Analysis failed."); }
} catch (err) { onExit(); }
setFinalReport(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Analysis failed");
}
} catch (err) {
console.error("Post-session analysis failure", err);
onExit();
}
};
const startSession = async () => {
try {
setSessionPhase('session');
setStatus('connecting');
setErrorMessage(null);
transcriptRef.current = "";
currentTurnUserText.current = "";
currentTurnPartnerText.current = "";
setLiveAnalysis(null);
setLiveMessages([]);
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
outputContextRef.current = new AudioContextClass({ sampleRate: 24000 });
inputContextRef.current = new AudioContextClass({ sampleRate: 16000 });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
useEffect(() => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Setup Analysis Model for Live Insights
analysisChatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nObjectives: ${scenario.cbtFocus.join(', ')}`,
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nFOCUS: ${scenario.cbtFocus.join(', ')}`,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
const init = async () => {
try {
inputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
outputAudioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 24000 });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const sessionPromise = ai.live.connect({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: `
${scenario.systemInstruction}
VOICE MODE: Keep responses short and empathetic.
INITIATION: When the user says "Let's start", "Hello", or initiates speech, begin with your opening: "${scenario.initialMessage}".
`,
outputAudioTranscription: {},
inputAudioTranscription: {},
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } } },
},
callbacks: {
onopen: () => {
setStatus('active');
if (!inputContextRef.current) return;
const source = inputContextRef.current.createMediaStreamSource(stream);
const processor = inputContextRef.current.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const data = e.inputBuffer.getChannelData(0);
const source = inputAudioCtxRef.current!.createMediaStreamSource(stream);
const scriptProcessor = inputAudioCtxRef.current!.createScriptProcessor(4096, 1, 1);
scriptProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// Calculate simple audio level for visualizer
let sum = 0;
for(let i=0; i<data.length; i+=10) sum += Math.abs(data[i]);
setVolumeLevel(Math.min((sum / (data.length/10)) * 500, 100));
const b64 = arrayBufferToBase64(floatTo16BitPCM(data));
sessionPromise.then(s => s.sendRealtimeInput({ media: { mimeType: 'audio/pcm;rate=16000', data: b64 } }));
for (let i = 0; i < inputData.length; i++) sum += inputData[i] * inputData[i];
setAudioLevel(Math.sqrt(sum / inputData.length) * 100);
if (isMuted) return;
const pcm16 = floatTo16BitPCM(inputData);
const base64 = encode(new Uint8Array(pcm16));
sessionPromise.then(session => {
session.sendRealtimeInput({
media: { data: base64, mimeType: 'audio/pcm;rate=16000' }
});
});
};
source.connect(processor);
processor.connect(inputContextRef.current.destination);
source.connect(scriptProcessor);
scriptProcessor.connect(inputAudioCtxRef.current!.destination);
},
onmessage: async (msg: LiveServerMessage) => {
if (msg.serverContent?.inputTranscription) {
const text = msg.serverContent.inputTranscription.text;
currentTurnUserText.current += text;
setLiveMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'user') return [...prev.slice(0, -1), { ...last, text: last.text + text }];
return [...prev, { id: Date.now().toString(), role: 'user', text }];
});
onmessage: async (message: LiveServerMessage) => {
// Handle Transcriptions
if (message.serverContent?.outputTranscription) {
currentOutputTranscription.current += message.serverContent.outputTranscription.text;
} else if (message.serverContent?.inputTranscription) {
currentInputTranscription.current += message.serverContent.inputTranscription.text;
}
if (msg.serverContent?.outputTranscription) {
const text = msg.serverContent.outputTranscription.text;
currentTurnPartnerText.current += text;
setLiveMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'model') return [...prev.slice(0, -1), { ...last, text: last.text + text }];
return [...prev, { id: Date.now().toString(), role: 'model', text }];
});
}
const audioData = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
if (audioData && outputContextRef.current) {
const bytes = base64ToUint8Array(audioData);
try {
const audioBuffer = await decodeAudioData(bytes, outputContextRef.current, 24000, 1);
const source = outputContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(outputContextRef.current.destination);
const now = outputContextRef.current.currentTime;
if (nextStartTimeRef.current < now) nextStartTimeRef.current = now + 0.05;
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += audioBuffer.duration;
sourcesRef.current.add(source);
source.onended = () => sourcesRef.current.delete(source);
} catch (e) { console.error("Audio decoding error:", e); }
}
if (msg.serverContent?.interrupted) {
sourcesRef.current.forEach(src => { try { src.stop(); } catch(e){} });
sourcesRef.current.clear();
if (outputContextRef.current) nextStartTimeRef.current = outputContextRef.current.currentTime;
}
if (msg.serverContent?.turnComplete) {
transcriptRef.current += `[USER]: ${currentTurnUserText.current}\n[PARTNER]: ${currentTurnPartnerText.current}\n`;
if (analysisChatRef.current) {
if (message.serverContent?.turnComplete) {
const userText = currentInputTranscription.current;
const modelText = currentOutputTranscription.current;
if (userText) {
const userMsg: Message = { id: Date.now().toString(), role: 'user', text: userText, timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
// Trigger Analysis
if (analysisChatRef.current && modelText) {
setIsAnalyzingTurn(true);
analysisChatRef.current.sendMessage({ message: `Evaluation Turn: User("${currentTurnUserText.current}") Partner("${currentTurnPartnerText.current}")` }).then(res => {
if (res.text) setLiveAnalysis(JSON.parse(res.text));
setIsAnalyzingTurn(false);
currentTurnUserText.current = "";
currentTurnPartnerText.current = "";
}).catch(() => setIsAnalyzingTurn(false));
}
}
},
onerror: () => { setStatus('error'); setErrorMessage("Voice bridge interrupted."); disconnectSession(); }
}
analysisChatRef.current.sendMessage({ message: `User: "${userText}"\nAI: "${modelText}"` })
.then((res: any) => {
try { if (res.text) setLiveInsight(JSON.parse(res.text)); }
catch(e) {} finally { setIsAnalyzingTurn(false); }
});
sessionRef.current = sessionPromise;
} catch (err: any) { setStatus('error'); }
}
}
if (modelText) {
setMessages(prev => [...prev, { id: (Date.now()+1).toString(), role: 'model', text: modelText, timestamp: new Date() }]);
}
currentInputTranscription.current = '';
currentOutputTranscription.current = '';
}
// Handle Audio Output
const audioBase64 = message.serverContent?.modelTurn?.parts[0]?.inlineData?.data;
if (audioBase64) {
setIsAiSpeaking(true);
const audioCtx = outputAudioCtxRef.current!;
nextStartTimeRef.current = Math.max(nextStartTimeRef.current, audioCtx.currentTime);
const buffer = await decodeAudioData(decode(audioBase64), audioCtx, 24000, 1);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.onended = () => {
audioSourcesRef.current.delete(source);
if (audioSourcesRef.current.size === 0) setIsAiSpeaking(false);
};
if (sessionPhase === 'briefing') {
source.start(nextStartTimeRef.current);
nextStartTimeRef.current += buffer.duration;
audioSourcesRef.current.add(source);
}
if (message.serverContent?.interrupted) {
stopAllAudio();
setIsAiSpeaking(false);
}
},
onclose: () => console.log("Live session closed"),
onerror: (e) => console.error("Live API error", e)
},
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } }
},
systemInstruction: `
${scenario.systemInstruction}
VIRTUAL REALITY ROLEPLAY PROTOCOL:
1. Stay strictly in character.
2. DO NOT provide advice or analysis within the conversation.
3. React naturally to the user's voice and emotional tone.
`,
inputAudioTranscription: {},
outputAudioTranscription: {}
}
});
sessionPromiseRef.current = sessionPromise;
} catch (err) {
console.error("Initialization failed", err);
}
};
init();
return () => {
stopAllAudio();
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
if (inputAudioCtxRef.current) inputAudioCtxRef.current.close();
if (outputAudioCtxRef.current) outputAudioCtxRef.current.close();
if (sessionPromiseRef.current) sessionPromiseRef.current.then(s => s.close());
};
}, [scenario, voiceSettings, stopAllAudio, isMuted]);
if (status === 'connecting') {
return (
<div className="flex flex-col items-center justify-center h-full bg-white rounded-3xl p-8 border border-slate-200 w-full max-w-2xl mx-auto shadow-2xl animate-in fade-in zoom-in-95 duration-500">
<div className="bg-slate-50 p-8 rounded-2xl border border-slate-200 mb-8 w-full relative overflow-hidden shadow-inner">
<div className="absolute top-0 right-0 p-6 opacity-5"><Activity size={120} /></div>
<div className="flex items-center gap-4 mb-6">
<div className="bg-teal-600 p-3 rounded-2xl text-white shadow-lg shadow-teal-100"><Sparkles size={24} /></div>
<div>
<h2 className="text-2xl font-bold text-slate-800">Ready to start?</h2>
<p className="text-xs text-teal-600 font-bold uppercase tracking-widest mt-1">Vocal Partner: {voiceSettings.voiceName} ({voiceSettings.vocalStyle})</p>
</div>
</div>
<div className="space-y-6">
<div>
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2 flex items-center gap-1.5"><Info size={12} /> Narrative Setting</p>
<div className="p-4 bg-white rounded-xl border border-slate-200 text-sm text-slate-700 leading-relaxed italic">"{scenario.description}"</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-xl border border-slate-200">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2">Techniques</p>
<div className="flex flex-wrap gap-1.5">
{scenario.cbtFocus.map(f => (<span key={f} className="text-[9px] font-bold text-teal-800 bg-teal-50 px-2 py-0.5 rounded border border-teal-100 uppercase tracking-tighter">{f}</span>))}
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-2">Profile</p>
<div className="flex items-center gap-2"><Headphones size={14} className="text-teal-500" /><span className="text-[10px] font-bold text-slate-500 uppercase">{voiceSettings.genderPreference} Speaker</span></div>
</div>
</div>
<div className="bg-slate-900 rounded-2xl p-6 text-white shadow-xl">
<div className="flex items-center gap-2 mb-2 text-teal-400"><Play size={16} fill="currentColor" /><span className="text-[10px] font-black uppercase tracking-widest">Protocol</span></div>
<p className="text-xs text-slate-400">Once active, say <span className="text-white font-bold">"Let's start"</span> to begin the interaction with your {voiceSettings.vocalStyle.toLowerCase()} partner.</p>
</div>
</div>
</div>
<div className="flex gap-4 w-full px-4">
<button onClick={onExit} className="flex-1 bg-white border border-slate-200 text-slate-500 py-4 rounded-2xl font-bold hover:bg-slate-50 transition-all text-xs uppercase tracking-widest">Back</button>
<button onClick={startSession} className="flex-[2] bg-teal-600 text-white py-4 rounded-2xl font-bold hover:bg-teal-700 transition-all shadow-xl flex items-center justify-center gap-3 text-xs uppercase tracking-widest group"><Mic size={18} className="group-hover:scale-110 transition-transform" />Establish Voice Link</button>
</div>
<div className="flex flex-col items-center justify-center h-full text-slate-800 animate-pulse">
<Loader2 size={48} className="animate-spin text-teal-600 mb-4" />
<p className="font-bold uppercase tracking-widest text-xs">Opening Neural Bridge...</p>
</div>
);
}
if (sessionPhase === 'analyzing') return (
<div className="flex flex-col items-center justify-center min-h-[500px] h-full bg-slate-50 rounded-3xl p-8 border border-slate-200">
<div className="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative"><BrainCircuit size={64} className="text-teal-600 animate-pulse" /><div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div></div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Analyzing Session</h2>
<p className="text-slate-500 text-center">Generating your clinical progress report...</p>
if (status === 'analyzing') {
return (
<div className="flex flex-col items-center justify-center h-full text-slate-800">
<div className="bg-white p-12 rounded-full shadow-lg border border-teal-100 mb-8 relative">
<BrainCircuit size={64} className="text-teal-600 animate-pulse" />
<div className="absolute inset-0 border-4 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 className="text-2xl font-bold mb-2">Generating Report</h2>
<p className="text-slate-500 text-center max-w-sm">Synthesizing vocal patterns and cognitive restructuring evidence...</p>
</div>
);
}
if (sessionPhase === 'finished' && finalAnalysis) return <SessionReport analysis={finalAnalysis} onExit={onExit} />;
if (status === 'finished' && finalReport) {
return <SessionReport analysis={finalReport} onExit={onExit} />;
}
return (
<div className={`flex flex-col lg:flex-row h-full max-h-[85vh] gap-4 transition-all duration-500 relative`}>
<div className={`flex flex-col items-center justify-center bg-slate-900 rounded-3xl relative overflow-hidden text-white p-8 shadow-2xl border border-slate-800 transition-all duration-500 ${showLiveInsights ? 'lg:w-[65%]' : 'w-full'}`}>
{status === 'active' && (<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20"><div className="w-80 h-80 bg-teal-500 rounded-full blur-[100px] transition-transform duration-150 ease-out" style={{ transform: `scale(${1 + volumeLevel/30})` }}></div></div>)}
<div className="absolute top-8 left-8 right-8 flex justify-between items-start z-10">
<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">
<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>
<div className="p-8 flex justify-between items-center relative z-10">
<div className="flex items-center gap-4">
<div className="bg-teal-600/20 p-2.5 rounded-xl border border-teal-500/30"><Headphones size={24} className="text-teal-400" /></div>
<button onClick={onExit} className="p-3 bg-white/5 hover:bg-white/10 rounded-2xl text-white/50 hover:text-white transition-all">
<PhoneOff size={20} />
</button>
<div>
<h2 className="text-lg font-bold text-white tracking-tight">{scenario.title}</h2>
<div className="flex items-center gap-2 mt-2"><div className={`w-1.5 h-1.5 rounded-full ${status === 'active' ? 'bg-teal-400 animate-pulse shadow-[0_0_8px_rgba(45,212,191,0.8)]' : 'bg-amber-400'}`} /><span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{status}</span></div>
<h2 className="text-white font-bold text-lg">{scenario.title}</h2>
<p className="text-teal-400 text-[10px] font-black uppercase tracking-widest">Active Roleplay Session</p>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setShowTranscript(!showTranscript)} className={`p-2.5 rounded-xl border ${showTranscript ? 'bg-teal-500 text-white border-teal-400' : 'bg-white/5 border-white/10 text-white/60'}`}><MessageSquare size={18} /></button>
<button onClick={() => setShowLiveInsights(!showLiveInsights)} className={`p-2.5 rounded-xl border ${showLiveInsights ? 'bg-teal-500 text-white border-teal-400' : 'bg-white/5 border-white/10 text-white/60'}`}><LayoutPanelLeft size={18} /></button>
<button onClick={onExit} className="bg-white/5 p-2.5 rounded-xl border border-white/10 text-white/60 hover:text-rose-400"><X size={18} /></button>
<button onClick={handleFinishSession} className="bg-teal-600 hover:bg-teal-700 text-white px-6 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-teal-900/40 flex items-center gap-2">
<Flag size={14} /> Finish
</button>
</div>
<div className="flex-grow flex flex-col items-center justify-center p-12 relative z-10">
<AudioVisualizer level={audioLevel} 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>
<div className="relative z-10 flex flex-col items-center gap-12 w-full">
<AudioVisualizer level={volumeLevel} active={status === 'active'} />
<div className={`w-32 h-32 rounded-full flex items-center justify-center ${status === 'active' ? 'bg-teal-500/10 ring-8 ring-teal-500/5' : 'bg-slate-800/50 ring-8 ring-slate-800'}`}><div className={`w-20 h-20 rounded-full flex items-center justify-center ${status === 'active' ? 'bg-teal-500 shadow-[0_0_60px_rgba(20,184,166,0.4)] scale-105' : 'bg-slate-700'}`}><Radio size={32} /></div></div>
<div className="text-center max-w-sm min-h-[100px] flex flex-col justify-center">
{status === 'connecting' && <p className="text-teal-400 animate-pulse">Initializing {voiceSettings.voiceName}...</p>}
{status === 'active' && (<div className="space-y-4 animate-in fade-in duration-700"><div className="bg-white/5 px-8 py-4 rounded-3xl border border-white/10 backdrop-blur-md"><p className="text-white font-medium text-lg">Listening...</p><p className="text-teal-400 text-[10px] uppercase tracking-widest">Partner: {voiceSettings.voiceName}</p></div></div>)}
<div className="p-8 flex justify-center gap-6 relative z-10 bg-black/20 backdrop-blur-md">
<button
onClick={() => setIsMuted(!isMuted)}
className={`p-5 rounded-full transition-all ${isMuted ? 'bg-rose-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={24} /> : <Mic size={24} />}
</button>
<button
onClick={() => setShowTranscript(!showTranscript)}
className={`p-5 rounded-full transition-all ${showTranscript ? 'bg-teal-600 text-white' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
<MessageSquare size={24} />
</button>
</div>
{showTranscript && <TranscriptPanel messages={messages} onClose={() => setShowTranscript(false)} />}
</div>
<div className="absolute bottom-12 z-10 w-full flex justify-center px-8">
{status === 'active' && (<button onClick={handleTerminateAndAnalyze} className="flex items-center gap-3 bg-white text-slate-900 px-12 py-4 rounded-2xl font-bold shadow-2xl transition-all active:scale-95 uppercase tracking-widest text-[10px]"><MicOff size={18} />Complete Session</button>)}
</div>
{showTranscript && <TranscriptPanel messages={liveMessages} onClose={() => setShowTranscript(false)} />}
</div>
{showLiveInsights && <LiveInsightPanel analysis={liveAnalysis} isAnalyzing={isAnalyzingTurn} />}
{/* Real-time Insights Module */}
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
</div>
);
};
export default VoiceSession;
export default React.memo(VoiceSession);