Janus-backend / frontend /src /components /guardian /DissonanceVisualizer.tsx
DevodG's picture
feat: stable janus intelligence with kaggle distillation
5f91e0b
'use client';
import React, { useState, useRef } from 'react';
import { getApiBaseUrl } from '@/lib/api';
import { motion, AnimatePresence } from 'framer-motion';
import { Mic, Square, UploadCloud, AlertTriangle, ShieldCheck, Activity, Play, Pause, X, BarChart2 } from 'lucide-react';
interface VADSpace {
valence: number;
arousal: number;
dominance: number;
}
interface AnalysisResult {
dissonance_score: number;
conflict_detected: boolean;
likely_sarcasm: boolean;
audio_dominant: string;
text_dominant: string;
contributions: {
audio_text_div: number;
prosody_boost: number;
text_confidence_penalty: number;
};
audio_vad: number[];
text_vad: number[];
prosody: {
pitch_mean: number;
rms_energy: number;
speech_rate: number;
};
error?: string;
}
export default function DissonanceVisualizer() {
const [transcript, setTranscript] = useState('');
const [audioFile, setAudioFile] = useState<File | Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioFile(audioBlob);
setAudioUrl(URL.createObjectURL(audioBlob));
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);
timerRef.current = setInterval(() => {
setRecordingTime((prev) => prev + 1);
}, 1000);
} catch (err) {
console.error('Error accessing microphone:', err);
setError('Could not access microphone. Please check permissions.');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current) clearInterval(timerRef.current);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setAudioFile(file);
setAudioUrl(URL.createObjectURL(file));
setError(null);
}
};
const resetState = () => {
setAudioFile(null);
setAudioUrl(null);
setResult(null);
setError(null);
setTranscript('');
setRecordingTime(0);
};
const analyzeDissonance = async () => {
if (!audioFile) {
setError('Please provide an audio recording.');
return;
}
if (!transcript.trim()) {
setError('Please provide a transcript.');
return;
}
setIsAnalyzing(true);
setError(null);
const formData = new FormData();
// Use .webm extension for blob, or keep original file name
formData.append('audio', audioFile, audioFile instanceof File ? audioFile.name : 'recording.webm');
formData.append('transcript', transcript);
try {
const res = await fetch(`${getApiBaseUrl()}/finance/analyze/emotion-conflict`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Analysis failed');
}
const data: AnalysisResult = await res.json();
setResult(data);
} catch (err: any) {
setError(err.message || 'An error occurred during analysis.');
} finally {
setIsAnalyzing(false);
}
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-8 pb-24">
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-3 bg-indigo-500/10 rounded-xl border border-indigo-500/20">
<Mic className="w-6 h-6 text-indigo-400" />
</div>
<div>
<h1 className="text-2xl font-light text-white tracking-wide">MMSA Deception Radar</h1>
<p className="text-sm text-gray-400 mt-1">Cross-Modal Emotion Conflict Engine</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Input Section */}
<div className="lg:col-span-5 flex flex-col gap-6">
<div className="bg-[#111118] border border-white/[0.05] rounded-2xl p-6 shadow-2xl relative overflow-hidden">
<h2 className="text-sm font-medium text-gray-300 uppercase tracking-widest mb-4 flex items-center gap-2">
<Activity className="w-4 h-4 text-indigo-400" /> Audio Intake
</h2>
{!audioFile ? (
<div className="space-y-4">
<div className="flex justify-center">
{isRecording ? (
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center animate-pulse">
<button
onClick={stopRecording}
className="w-14 h-14 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)]"
>
<Square className="w-6 h-6 fill-current" />
</button>
</div>
</div>
<div className="text-xl font-mono text-red-400 tracking-wider">
{formatTime(recordingTime)}
</div>
</div>
) : (
<button
onClick={startRecording}
className="w-20 h-20 bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-full flex items-center justify-center text-indigo-400 transition-all hover:scale-105"
>
<Mic className="w-8 h-8" />
</button>
)}
</div>
{!isRecording && (
<div className="text-center">
<p className="text-xs text-gray-500 mb-3">OR</p>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
ref={fileInputRef}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.05] rounded-lg text-sm text-gray-300 transition-all"
>
<UploadCloud className="w-4 h-4" /> Upload Audio File
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-white/[0.02] border border-white/[0.05] rounded-xl flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-500/20 rounded-full flex items-center justify-center">
<Mic className="w-5 h-5 text-indigo-400" />
</div>
<div>
<p className="text-sm text-gray-200">Audio Captured</p>
{audioUrl && <audio src={audioUrl} controls className="h-8 mt-2 w-full max-w-[200px]" />}
</div>
</div>
<button onClick={resetState} className="p-2 text-gray-500 hover:text-red-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
<div className="bg-[#111118] border border-white/[0.05] rounded-2xl p-6 shadow-2xl relative overflow-hidden flex-1 flex flex-col">
<h2 className="text-sm font-medium text-gray-300 uppercase tracking-widest mb-4">
Transcript
</h2>
<textarea
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder="Enter the exact spoken words to compare against the vocal tone..."
className="w-full bg-black/40 border border-white/[0.1] rounded-xl p-4 text-gray-200 placeholder-gray-600 focus:outline-none focus:border-indigo-500/50 resize-none flex-1 min-h-[150px]"
/>
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
<button
onClick={analyzeDissonance}
disabled={!audioFile || !transcript.trim() || isAnalyzing}
className={`mt-4 w-full py-3 rounded-xl flex items-center justify-center gap-2 font-medium transition-all ${
!audioFile || !transcript.trim() || isAnalyzing
? 'bg-white/[0.05] text-gray-500 cursor-not-allowed'
: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-[0_0_15px_rgba(79,70,229,0.4)]'
}`}
>
{isAnalyzing ? (
<>
<Activity className="w-5 h-5 animate-pulse" /> Processing Audio Tensor...
</>
) : (
'Compute Dissonance'
)}
</button>
</div>
</div>
{/* Results Section */}
<div className="lg:col-span-7">
<AnimatePresence mode="wait">
{isAnalyzing ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full bg-[#111118] border border-white/[0.05] rounded-2xl flex flex-col items-center justify-center p-12 relative overflow-hidden min-h-[500px]"
>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(79,70,229,0.15)_0%,transparent_70%)] opacity-50 animate-pulse" />
<div className="relative w-32 h-32 mb-8 flex items-center justify-center">
<div className="absolute inset-0 border-t-2 border-indigo-500 rounded-full animate-spin [animation-duration:1.5s]" />
<div className="absolute inset-2 border-r-2 border-purple-500 rounded-full animate-spin [animation-duration:2s] [animation-direction:reverse]" />
<div className="absolute inset-4 border-b-2 border-emerald-500 rounded-full animate-spin [animation-duration:3s]" />
<Activity className="w-8 h-8 text-indigo-400 animate-pulse" />
</div>
<h3 className="text-lg font-light text-gray-200 tracking-wider">Extracting wav2vec2 prosody...</h3>
<p className="text-sm text-gray-500 mt-2">Projecting modalities into VAD space</p>
</motion.div>
) : result ? (
<motion.div
key="results"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Verdict Banner */}
<div className={`p-6 rounded-2xl border flex items-center gap-4 ${
result.conflict_detected
? 'bg-red-500/10 border-red-500/30'
: 'bg-emerald-500/10 border-emerald-500/30'
}`}>
<div className={`p-3 rounded-full ${
result.conflict_detected ? 'bg-red-500/20 text-red-400' : 'bg-emerald-500/20 text-emerald-400'
}`}>
{result.conflict_detected ? <AlertTriangle className="w-8 h-8" /> : <ShieldCheck className="w-8 h-8" />}
</div>
<div>
<h2 className={`text-2xl font-semibold tracking-wide ${
result.conflict_detected ? 'text-red-400' : 'text-emerald-400'
}`}>
{result.likely_sarcasm ? 'High Probability Sarcasm/Irony' :
result.conflict_detected ? 'Emotional Dissonance Detected' : 'Signals Aligned'}
</h2>
<p className="text-gray-400 mt-1">
{result.conflict_detected
? 'Audio tone significantly conflicts with text transcript sentiment.'
: 'Audio delivery matches the semantic meaning of the text.'}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Gauge */}
<div className="bg-[#111118] border border-white/[0.05] rounded-2xl p-6 shadow-xl flex flex-col items-center justify-center">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6 w-full text-left">Divergence Score</h3>
<div className="relative w-48 h-48 flex items-center justify-center">
<svg className="absolute inset-0 w-full h-full transform -rotate-90">
<circle cx="96" cy="96" r="80" fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="12" />
<circle
cx="96" cy="96" r="80" fill="none"
stroke={result.conflict_detected ? '#ef4444' : '#10b981'}
strokeWidth="12"
strokeDasharray="502"
strokeDashoffset={502 - (502 * Math.min(result.dissonance_score, 1))}
className="transition-all duration-1000 ease-out"
/>
</svg>
<div className="text-center">
<span className="text-4xl font-light text-white">{(result.dissonance_score).toFixed(2)}</span>
<p className="text-xs text-gray-500 mt-1">Cosine Distance</p>
</div>
</div>
</div>
{/* Dominant Emotions */}
<div className="bg-[#111118] border border-white/[0.05] rounded-2xl p-6 shadow-xl flex flex-col justify-between">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-4">Detected Sentiments</h3>
<div className="space-y-6">
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-300 flex items-center gap-2"><Mic className="w-4 h-4"/> Audio Tone</span>
<span className="text-indigo-400 font-medium uppercase">{result.audio_dominant}</span>
</div>
<div className="h-2 w-full bg-white/[0.05] rounded-full overflow-hidden">
<div className="h-full bg-indigo-500 w-full" />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-300 flex items-center gap-2"><Square className="w-4 h-4"/> Transcript Meaning</span>
<span className="text-purple-400 font-medium uppercase">{result.text_dominant}</span>
</div>
<div className="h-2 w-full bg-white/[0.05] rounded-full overflow-hidden">
<div className="h-full bg-purple-500 w-full" />
</div>
</div>
</div>
</div>
</div>
{/* VAD Space Visualization */}
<div className="bg-[#111118] border border-white/[0.05] rounded-2xl p-6 shadow-xl">
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6">3D VAD Space Projection</h3>
<div className="space-y-5">
{[
{ label: 'Valence (Negative → Positive)', idx: 0 },
{ label: 'Arousal (Calm → Excited)', idx: 1 },
{ label: 'Dominance (Submissive → Dominant)', idx: 2 }
].map((dim) => (
<div key={dim.label} className="relative">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{dim.label.split(' ')[0]}</span>
</div>
<div className="h-6 w-full bg-white/[0.03] border border-white/[0.05] rounded-lg relative">
{/* Center line */}
<div className="absolute top-0 bottom-0 left-1/2 w-px bg-white/[0.1]" />
{/* Audio marker */}
<div
className="absolute top-1 bottom-1 w-3 bg-indigo-500 rounded shadow-[0_0_8px_rgba(99,102,241,0.6)] z-10 transition-all duration-1000"
style={{ left: `calc(${result.audio_vad[dim.idx] * 100}% - 6px)` }}
title={`Audio: ${result.audio_vad[dim.idx].toFixed(2)}`}
/>
{/* Text marker */}
<div
className="absolute top-2 bottom-2 w-2 bg-purple-500 rounded shadow-[0_0_8px_rgba(168,85,247,0.6)] z-20 transition-all duration-1000"
style={{ left: `calc(${result.text_vad[dim.idx] * 100}% - 4px)` }}
title={`Text: ${result.text_vad[dim.idx].toFixed(2)}`}
/>
</div>
</div>
))}
<div className="flex justify-end gap-4 mt-4 pt-4 border-t border-white/[0.05]">
<div className="flex items-center gap-2 text-xs text-gray-400">
<div className="w-3 h-3 bg-indigo-500 rounded-sm" /> Audio Embedding
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<div className="w-3 h-3 bg-purple-500 rounded-sm" /> Text Embedding
</div>
</div>
</div>
</div>
</motion.div>
) : (
<div className="h-full bg-[#111118]/50 border border-white/[0.02] rounded-2xl flex flex-col items-center justify-center p-12 text-center border-dashed min-h-[500px]">
<BarChart2 className="w-16 h-16 text-gray-700 mb-4" />
<h3 className="text-xl font-light text-gray-500">Awaiting Modalities</h3>
<p className="text-sm text-gray-600 mt-2 max-w-sm">
Record or upload audio and provide a transcript to project vectors into the emotional dissonance space.
</p>
</div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}