feat: Add user settings for voice and persona

Introduces a new `UserSettings` interface and default settings. This allows users to customize voice preferences (name, style, gender) and persona characteristics within the application. This enhances personalization and user experience by tailoring interactions to individual needs and preferences.
This commit is contained in:
John Remillard committed 2025-12-17 12:14:29 -05:00
1 parent c78604de02
commit a7a8c661b7
9 files changed
+670 -360

No files matched your search

+139 -221
View File
@@ -1,48 +1,54 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
// Corrected import: AnalysisResult is defined in types.ts, not LiveInsightPanel.tsx
import { Scenario, AnalysisResult } from '../types';
import { Scenario, AnalysisResult, UserSettings } from '../types';
import { GoogleGenAI, LiveServerMessage, Modality, Chat } from "@google/genai";
import { Mic, MicOff, X, Radio, AlertCircle, Headphones, BrainCircuit, LayoutPanelLeft } from 'lucide-react';
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';
// Sub-components
import AudioVisualizer from './AudioVisualizer';
// Corrected import: LiveInsightPanel is a default export, AnalysisResult is already imported from types
import LiveInsightPanel from './LiveInsightPanel';
import SessionReport from './SessionReport';
import Tooltip from './Tooltip';
import TranscriptPanel from './TranscriptPanel';
interface VoiceSessionProps {
scenario: Scenario;
onExit: () => void;
voiceSettings: UserSettings;
}
const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit }) => {
const [status, setStatus] = useState<'disconnected' | 'connecting' | 'connected' | 'error' | 'analyzing' | 'finished'>('disconnected');
interface DisplayMessage {
id: string;
role: 'user' | 'model';
text: string;
}
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);
// Live Analysis State
const [showLiveInsights, setShowLiveInsights] = useState(false);
const [showTranscript, setShowTranscript] = useState(false);
const [liveAnalysis, setLiveAnalysis] = useState<AnalysisResult | null>(null);
const [isAnalyzingTurn, setIsAnalyzingTurn] = useState(false);
const [showLiveInsights, setShowLiveInsights] = useState(false);
const [liveMessages, setLiveMessages] = useState<DisplayMessage[]>([]);
// Audio Refs
const inputContextRef = useRef<AudioContext | null>(null);
const outputContextRef = useRef<AudioContext | null>(null);
const nextStartTimeRef = useRef<number>(0);
const sourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
// Transcript accumulation
const transcriptRef = useRef<string>("");
const currentTurnUserText = useRef<string>("");
const currentTurnPartnerText = useRef<string>("");
// AI Refs
const sessionRef = useRef<any>(null);
const analysisChatRef = useRef<Chat | null>(null);
// Visualization
const [volumeLevel, setVolumeLevel] = useState(0);
useEffect(() => {
@@ -52,336 +58,248 @@ const VoiceSession: React.FC<VoiceSessionProps> = ({ scenario, onExit }) => {
}, []);
const disconnectSession = useCallback(async () => {
sourcesRef.current.forEach(source => {
try { source.stop(); } catch(e) {}
});
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);
}
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);
}
if (session && typeof session.close === 'function') session.close();
} catch (e) { console.error("Error closing session:", e); }
sessionRef.current = null;
}
}, []);
const handleTerminateAndAnalyze = async () => {
await disconnectSession();
if (transcriptRef.current.length < 10) {
setStatus('disconnected');
onExit();
return;
}
setStatus('analyzing');
if (transcriptRef.current.length < 10) return onExit();
setSessionPhase('analyzing');
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const systemPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}
SCENARIO CONTEXT:
Title: ${scenario.title}
Description: ${scenario.description}
CBT Focus: ${scenario.cbtFocus.join(', ')}`;
const result = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: systemPrompt,
systemInstruction: `${SYSTEM_SUMMARY_ANALYSIS_PROMPT}\n\nSCENARIO: ${scenario.title}\nCBT: ${scenario.cbtFocus.join(', ')}`,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
},
contents: [{ role: 'user', parts: [{ text: `SESSION TRANSCRIPT:\n${transcriptRef.current}` }] }]
contents: [{ role: 'user', parts: [{ text: `TRANSCRIPT:\n${transcriptRef.current}` }] }]
});
if (result.text) {
setFinalAnalysis(JSON.parse(result.text));
setStatus('finished');
} else {
throw new Error("Analysis synthesis failed.");
}
} catch (err) {
console.error("Post-session analysis error", err);
setStatus('disconnected');
onExit();
}
setSessionPhase('finished');
} else { throw new Error("Analysis failed."); }
} catch (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 });
if (outputContextRef.current.state === 'suspended') await outputContextRef.current.resume();
if (inputContextRef.current.state === 'suspended') await inputContextRef.current.resume();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
analysisChatRef.current = ai.chats.create({
model: 'gemini-3-flash-preview',
config: {
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\n\nSCENARIO CONTEXT:\nTitle: ${scenario.title}\nDescription: ${scenario.description}\nCBT Focus: ${scenario.cbtFocus.join(', ')}`,
systemInstruction: `${SYSTEM_ANALYSIS_PROMPT}\nSCENARIO: ${scenario.title}\nObjectives: ${scenario.cbtFocus.join(', ')}`,
responseMimeType: 'application/json',
responseSchema: CBT_ANALYSIS_SCHEMA,
}
});
const effectiveSystemInstruction = `
${scenario.systemInstruction}
IMPORTANT: This is a voice conversation. Keep responses concise (1-2 sentences). You are strictly in character.
START THE CONVERSATION IMMEDIATELY by saying: "${scenario.initialMessage}"
`;
const sessionPromise = ai.live.connect({
model: 'gemini-2.5-flash-native-audio-preview-09-2025',
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: effectiveSystemInstruction,
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: 'Puck' } },
},
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceSettings.voiceName } } },
},
callbacks: {
onopen: () => {
setStatus('connected');
setStatus('active');
if (!inputContextRef.current) return;
const source = inputContextRef.current.createMediaStreamSource(stream);
const processor = inputContextRef.current.createScriptProcessor(4096, 1, 1);
const gainNode = inputContextRef.current.createGain();
gainNode.gain.value = 0;
processor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
const data = e.inputBuffer.getChannelData(0);
let sum = 0;
for(let i=0; i<inputData.length; i+=10) sum += Math.abs(inputData[i]);
setVolumeLevel(Math.min((sum / (inputData.length/10)) * 500, 100));
const pcmData = floatTo16BitPCM(inputData);
const b64Data = arrayBufferToBase64(pcmData);
sessionPromise.then(session => {
session.sendRealtimeInput({
media: { mimeType: 'audio/pcm;rate=16000', data: b64Data }
});
}).catch(err => console.error("Error sending audio:", err));
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 } }));
};
source.connect(processor);
processor.connect(gainNode);
gainNode.connect(inputContextRef.current.destination);
processor.connect(inputContextRef.current.destination);
},
onmessage: async (msg: LiveServerMessage) => {
if (msg.serverContent?.inputTranscription) {
const text = msg.serverContent.inputTranscription.text;
currentTurnUserText.current += text;
transcriptRef.current += `[USER]: ${text}\n`;
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 }];
});
}
if (msg.serverContent?.outputTranscription) {
const text = msg.serverContent.outputTranscription.text;
currentTurnPartnerText.current += text;
transcriptRef.current += `[PARTNER]: ${text}\n`;
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 ctx = outputContextRef.current;
const bytes = base64ToUint8Array(audioData);
try {
const audioBuffer = await decodeAudioData(bytes, ctx, 24000, 1);
const source = ctx.createBufferSource();
const audioBuffer = await decodeAudioData(bytes, outputContextRef.current, 24000, 1);
const source = outputContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
const now = ctx.currentTime;
if (nextStartTimeRef.current < now) nextStartTimeRef.current = now;
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.addEventListener('ended', () => sourcesRef.current.delete(source));
} catch (decodingErr) { console.error("Audio decoding error", decodingErr); }
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) {
if (analysisChatRef.current && (currentTurnUserText.current || currentTurnPartnerText.current)) {
transcriptRef.current += `[USER]: ${currentTurnUserText.current}\n[PARTNER]: ${currentTurnPartnerText.current}\n`;
if (analysisChatRef.current) {
setIsAnalyzingTurn(true);
const turnContext = `User said: "${currentTurnUserText.current}". Roleplay partner said: "${currentTurnPartnerText.current}".`;
analysisChatRef.current.sendMessage({ message: turnContext }).then(res => {
try {
if (res.text) {
setLiveAnalysis(JSON.parse(res.text));
if (!showLiveInsights) setShowLiveInsights(true);
}
} catch (e) { console.error("Failed to parse live analysis", e); } finally {
setIsAnalyzingTurn(false);
currentTurnUserText.current = "";
currentTurnPartnerText.current = "";
}
});
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));
}
}
},
onclose: () => { if (status !== 'error' && status !== 'analyzing' && status !== 'finished') setStatus('disconnected'); },
onerror: (err) => {
console.error("Session error", err);
setErrorMessage("Clinical bridge interrupted. Resetting connection.");
setStatus('error');
disconnectSession();
}
onerror: () => { setStatus('error'); setErrorMessage("Voice bridge interrupted."); disconnectSession(); }
}
});
sessionRef.current = sessionPromise;
} catch (err: any) {
console.error("Failed to start session", err);
setErrorMessage(err.message || "Microphone access denied or link failure.");
setStatus('error');
}
} catch (err: any) { setStatus('error'); }
};
if (status === 'analyzing') {
if (sessionPhase === 'briefing') {
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 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>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Synthesizing Clinical Insights</h2>
<p className="text-slate-500 text-center max-w-sm">Please wait while the engine analyzes your vocal patterns and cognitive restructuring efficiency.</p>
</div>
);
}
if (status === 'finished' && finalAnalysis) {
return <SessionReport analysis={finalAnalysis} onExit={onExit} />;
}
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>
</div>
);
if (sessionPhase === 'finished' && finalAnalysis) return <SessionReport analysis={finalAnalysis} onExit={onExit} />;
return (
<div className={`flex flex-col lg:flex-row h-full max-h-[85vh] gap-4 transition-all duration-500`}>
{/* Main Voice Hub */}
<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 === 'connected' && (
<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>
)}
{/* Header */}
{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 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>
<div className="bg-teal-600/20 p-2.5 rounded-xl border border-teal-500/30"><Headphones size={24} className="text-teal-400" /></div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">{scenario.title}</h2>
<div className="flex items-center gap-2 mt-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${status === 'connected' ? 'bg-teal-400 animate-pulse' : status === 'connecting' ? 'bg-amber-400 animate-bounce' : 'bg-rose-400'}`} />
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{status}</span>
</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>
</div>
</div>
<div className="flex gap-2">
{status === 'connected' && (
<button
onClick={() => setShowLiveInsights(!showLiveInsights)}
className={`p-2.5 rounded-xl transition-all backdrop-blur-md border ${showLiveInsights ? 'bg-teal-500 border-teal-400 text-white' : 'bg-white/5 border-white/10 text-white/60 hover:text-white'}`}
title="Toggle Live Insights"
>
<LayoutPanelLeft size={20} />
</button>
)}
<button
onClick={onExit}
className="bg-white/5 hover:bg-white/10 p-2.5 rounded-xl transition-all backdrop-blur-md border border-white/10 text-white/60 hover:text-white"
>
<X size={20} />
</button>
<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>
</div>
</div>
<div className="relative z-10 flex flex-col items-center gap-10 w-full">
<div className={`w-full max-w-lg mx-auto flex flex-col items-center gap-8 transition-all duration-700`}>
<AudioVisualizer level={volumeLevel} active={status === 'connected'} />
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-500 ${status === 'connected' ? 'bg-teal-500/10 ring-8 ring-teal-500/20' : 'bg-slate-800/50 ring-8 ring-slate-800'}`}>
<div className={`w-20 h-20 rounded-full flex items-center justify-center ${status === 'connected' ? 'bg-teal-500 shadow-[0_0_50px_rgba(20,184,166,0.3)]' : 'bg-slate-700'}`}>
<Radio size={32} className={`text-white ${status === 'connected' ? 'animate-pulse' : 'opacity-20'}`} />
</div>
</div>
</div>
<div className="text-center max-w-sm mt-4 min-h-[60px]">
{status === 'disconnected' && <p className="text-slate-400 text-sm font-light leading-relaxed">System ready for audio link.<br/>Please proceed when in a quiet environment.</p>}
{status === 'connecting' && <p className="text-teal-400 text-sm font-bold uppercase tracking-widest animate-pulse">Establishing Bi-Directional Bridge...</p>}
{status === 'connected' && <p className="text-white font-medium text-lg">Active Session. Listening...</p>}
{status === 'error' && (
<div className="flex items-center gap-2 text-rose-300 bg-rose-950/40 border border-rose-500/30 px-5 py-3 rounded-xl">
<AlertCircle size={18} />
<p className="text-sm font-medium">{errorMessage}</p>
</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>
</div>
<div className="absolute bottom-12 z-10">
{status !== 'connected' ? (
<button
onClick={startSession}
disabled={status === 'connecting'}
className="flex items-center gap-3 bg-teal-600 hover:bg-teal-500 disabled:bg-teal-800 text-white px-10 py-4 rounded-2xl font-bold shadow-xl transition-all transform hover:scale-105 active:scale-95 border border-teal-400/20"
>
<Mic size={24} />
{status === 'connecting' ? 'SYNCING...' : 'INITIALIZE LINK'}
</button>
) : (
<button
onClick={handleTerminateAndAnalyze}
className="flex items-center gap-3 bg-slate-100 hover:bg-white text-slate-900 px-10 py-4 rounded-2xl font-bold shadow-xl transition-all active:scale-95"
>
<MicOff size={24} />
TERMINATE SESSION
</button>
)}
<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} />}
</div>
);
};
export default VoiceSession;
export default VoiceSession;