- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer - Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support - Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline - Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots) - Migrate core application to Svelte 5 runes (, , ) with reactive stores - Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
214 lines
8.1 KiB
TypeScript
214 lines
8.1 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { Scenario, UserSettings, AnalysisResult } from '../types';
|
|
import { useLiveSession } from '../hooks/useLiveSession';
|
|
import { AlertCircle, Settings2, X, AlertTriangle } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import { generateSessionSummaryReport } from '../services/openRouterService';
|
|
import { saveSession } from '../services/sessionStorage';
|
|
|
|
// 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,
|
|
errorMessage,
|
|
isMuted,
|
|
toggleMute,
|
|
audioLevel,
|
|
aiAudioLevel,
|
|
isAiSpeaking,
|
|
isUserSpeaking,
|
|
messages,
|
|
liveInsight,
|
|
isAnalyzingTurn,
|
|
isSilenceDetected,
|
|
startSession
|
|
} = useLiveSession(scenario, voiceSettings);
|
|
|
|
const activeLevel = isAiSpeaking ? aiAudioLevel : audioLevel;
|
|
|
|
const handleStart = useCallback(() => {
|
|
setHasStarted(true);
|
|
startSession();
|
|
}, [startSession]);
|
|
|
|
const handleFinishSession = useCallback(async () => {
|
|
setStatus('analyzing');
|
|
|
|
try {
|
|
const reportData = await generateSessionSummaryReport(
|
|
messages,
|
|
scenario.title,
|
|
scenario.description,
|
|
{
|
|
apiKey: voiceSettings.openRouterApiKey,
|
|
model: voiceSettings.supervisorModel
|
|
}
|
|
);
|
|
|
|
setFinalReport(reportData);
|
|
saveSession(scenario, 'voice', messages, reportData);
|
|
setStatus('finished');
|
|
} catch (err) {
|
|
console.error("Post-session analysis failure", err);
|
|
onExit();
|
|
}
|
|
}, [messages, scenario, voiceSettings.openRouterApiKey, voiceSettings.supervisorModel, setStatus, onExit]);
|
|
|
|
if (status === 'connecting') return <ConnectionState />;
|
|
if (status === 'analyzing') return <AnalyzingState />;
|
|
if (status === 'finished' && finalReport) {
|
|
return (
|
|
<SessionReport
|
|
analysis={finalReport}
|
|
scenarioTitle={scenario.title}
|
|
messages={messages}
|
|
onExit={onExit}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-slate-900 rounded-3xl border border-white/10 overflow-hidden relative shadow-2xl">
|
|
{/* Voice Session Header */}
|
|
<VoiceHeader
|
|
scenario={scenario}
|
|
onExit={onExit}
|
|
onOpenDeviceSelector={() => setShowDeviceSelector(true)}
|
|
/>
|
|
|
|
{/* Main Interaction Stage */}
|
|
<div className="flex-grow flex flex-col lg:flex-row items-center justify-between p-6 md:p-12 relative overflow-hidden gap-8">
|
|
<div className="flex-grow flex flex-col items-center justify-center w-full relative z-10">
|
|
{/* Error Banner */}
|
|
{status === 'error' && (
|
|
<div className="mb-6 p-4 rounded-2xl bg-rose-950/80 border border-rose-500/50 text-rose-200 flex items-center gap-3 max-w-md animate-in fade-in">
|
|
<AlertTriangle className="text-rose-400 shrink-0" size={20} />
|
|
<div className="text-xs">
|
|
<p className="font-bold">Connection Warning</p>
|
|
<p className="opacity-90">{errorMessage || 'Unable to connect to live voice session. Please verify your OpenRouter API key.'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Silence Warning Notice */}
|
|
{isSilenceDetected && !isMuted && status === 'active' && (
|
|
<div className="absolute top-0 flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 px-4 py-2 rounded-full text-xs animate-bounce shadow-lg">
|
|
<AlertCircle size={14} />
|
|
<span>Low microphone input detected. Please speak closer to your mic.</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Audio Visualizer Orb */}
|
|
<div className="relative mb-8 flex items-center justify-center">
|
|
<AudioVisualizer
|
|
level={activeLevel}
|
|
isAiSpeaking={isAiSpeaking}
|
|
isUserSpeaking={isUserSpeaking}
|
|
isMuted={isMuted}
|
|
/>
|
|
|
|
{!hasStarted && status === 'active' && (
|
|
<Button
|
|
onClick={handleStart}
|
|
className="absolute z-20 bg-teal-500 hover:bg-teal-400 text-slate-950 font-black px-8 py-6 rounded-full text-sm uppercase tracking-widest shadow-xl shadow-teal-500/20 hover:scale-105 transition-all"
|
|
>
|
|
Begin Voice Simulation
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Indicators */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 backdrop-blur-md">
|
|
<span className={`w-2 h-2 rounded-full ${isAiSpeaking ? 'bg-blue-400 animate-ping' : isUserSpeaking ? 'bg-teal-400 animate-pulse' : 'bg-slate-500'}`} />
|
|
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-300">
|
|
{isAiSpeaking ? `${scenario.characterName} is speaking` : isUserSpeaking ? 'Listening to you...' : 'Ready for dialogue'}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-400 font-light mt-1">
|
|
{voiceSettings.voiceName} Vocal Persona Active
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live Insight Drawer */}
|
|
<div className="w-full lg:w-96 flex-shrink-0 h-full max-h-[500px] lg:max-h-none flex flex-col justify-end">
|
|
<LiveInsightPanel analysis={liveInsight} isAnalyzing={isAnalyzingTurn} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Persistent Audio Controls Footer */}
|
|
<VoiceControls
|
|
isMuted={isMuted}
|
|
onToggleMute={toggleMute}
|
|
onFinish={handleFinishSession}
|
|
onToggleTranscript={() => setShowTranscript(prev => !prev)}
|
|
showTranscript={showTranscript}
|
|
messageCount={messages.length}
|
|
/>
|
|
|
|
{/* Transcript Drawer Slideout */}
|
|
{showTranscript && (
|
|
<TranscriptPanel
|
|
messages={messages}
|
|
onClose={() => setShowTranscript(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Audio Hardware Selector Modal */}
|
|
{showDeviceSelector && (
|
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
|
<div className="bg-slate-900 border border-white/10 rounded-3xl p-6 max-w-md w-full shadow-2xl">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-sm font-bold uppercase tracking-widest text-white flex items-center gap-2">
|
|
<Settings2 size={16} className="text-teal-400" />
|
|
Select Audio Input Device
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowDeviceSelector(false)}
|
|
className="text-slate-400 hover:text-white p-1"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<AudioDeviceSelector
|
|
selectedDeviceId={voiceSettings.preferredAudioDeviceId}
|
|
onSelect={(deviceId) => {
|
|
onUpdateSettings({ ...voiceSettings, preferredAudioDeviceId: deviceId });
|
|
setShowDeviceSelector(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(VoiceSession);
|