| |
| |
| |
| |
|
|
| |
| const state = { |
| isRunning: false, |
| ws: null, |
| webcamStream: null, |
| audioContext: null, |
| audioAnalyser: null, |
| audioStream: null, |
| mediaRecorder: null, |
| audioChunks: [], |
| frameInterval: null, |
| engagementHistory: [], |
| blinkTimestamps: [], |
| blinkCount: 0, |
| stableFramesCount: 0, |
| lastBlinkState: false, |
| lastNoseX: null, |
| lastNoseY: null, |
| sessionStartTime: null, |
| peakEngagement: 0, |
| totalEngagement: 0, |
| engagementSamples: 0, |
| faceMesh: null, |
| camera: null, |
| emotionBuffer: [], |
| lastStableEmotion: 'Neutral', |
| lastStableConfidence: 0, |
| faceMeshReady: false, |
| meshFailCount: 0, |
|
|
| |
| calibrationFrames: [], |
| baseline: null, |
| |
| neutralStableFrames: 0, |
| mouthOpenFrames: 0, |
| }; |
|
|
| |
| const video = document.getElementById('webcam-video'); |
| const meshCanvas = document.getElementById('mesh-canvas'); |
| const meshCtx = meshCanvas.getContext('2d'); |
| const logContent = document.getElementById('log-content'); |
| const btnStartStop = document.getElementById('btn-start-stop'); |
|
|
| |
| (function initMiniBars() { |
| const container = document.getElementById('mini-audio-viz'); |
| if (!container) return; |
| for (let i = 0; i < 32; i++) { |
| const bar = document.createElement('div'); |
| bar.className = 'audio-bar'; |
| bar.style.background = `rgba(209, 188, 255, ${0.3 + Math.random() * 0.5})`; |
| bar.style.height = '2px'; |
| container.appendChild(bar); |
| } |
| })(); |
|
|
| |
| function addLog(tag, message, color) { |
| const p = document.createElement('p'); |
| p.className = 'log-line'; |
| const elapsed = state.sessionStartTime |
| ? ((Date.now() - state.sessionStartTime) / 1000).toFixed(3) |
| : '0.000'; |
| p.innerHTML = `<span style="color:${color};">[${elapsed}s]</span> ${tag} :: ${message}`; |
| logContent.appendChild(p); |
| logContent.scrollTop = logContent.scrollHeight; |
| while (logContent.children.length > 100) logContent.removeChild(logContent.firstChild); |
| } |
|
|
| |
| const API_BASE = "https://r-vasanthkumar73-dev-sentinel-multimodal-emotion-ai.hf.space"; |
| const speechApiUrl = `${API_BASE}/api/analyze/speech`; |
| const textApiUrl = `${API_BASE}/api/analyze/text`; |
| const sessionSaveUrl = `${API_BASE}/api/session/save`; |
|
|
| |
| btnStartStop.addEventListener('click', () => { |
| ensureAudioContext(); |
| if (state.isRunning) stopAll(); |
| else startAll(); |
| }); |
|
|
| async function startAll() { |
| state.isRunning = true; |
| state.sessionStartTime = Date.now(); |
| state.blinkTimestamps = []; |
| state.blinkCount = 0; |
| state.lastNoseX = null; |
| state.lastNoseY = null; |
| state.peakEngagement = 0; |
| state.totalEngagement = 0; |
| state.engagementSamples = 0; |
| state.engagementHistory = []; |
| state.emotionBuffer = []; |
| state.lastStableEmotion = 'Neutral'; |
| state.lastStableConfidence = 0; |
| state.meshFailCount = 0; |
| state.calibrationFrames = []; |
| state.baseline = null; |
|
|
| btnStartStop.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">stop</span> Stop Monitoring'; |
| document.getElementById('live-badge').style.display = 'flex'; |
| document.getElementById('log-status').textContent = 'LIVE_PKT_RECEIVED'; |
| document.getElementById('scan-line').style.display = 'block'; |
| document.getElementById('connection-status').innerHTML = '<span class="status-dot"></span><span class="status-text">Connected</span>'; |
|
|
| addLog('SYSTEM', 'Starting multimodal analysis engine...', 'var(--primary-container)'); |
|
|
| try { |
| state.webcamStream = await navigator.mediaDevices.getUserMedia({ |
| video: { width: 640, height: 480, facingMode: 'user' }, |
| audio: false |
| }); |
| video.srcObject = state.webcamStream; |
| document.getElementById('face-status').textContent = 'Active Stream'; |
| document.getElementById('source-face').classList.add('active'); |
| const ctrlCam = document.getElementById('ctrl-camera'); if (ctrlCam) ctrlCam.classList.add('active'); |
| addLog('CAMERA', 'Webcam stream initialized (640x480)', 'var(--primary-container)'); |
| } catch (e) { |
| addLog('CAMERA', 'Failed to access webcam: ' + e.message, 'var(--error)'); |
| } |
|
|
| document.getElementById('audio-status').textContent = 'Standby (Click Audio Panel)'; |
| startFaceMesh(); |
| } |
|
|
| function stopAll() { |
| state.isRunning = false; |
| btnStartStop.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">play_arrow</span> Start Monitoring'; |
| document.getElementById('live-badge').style.display = 'none'; |
| document.getElementById('log-status').textContent = 'STANDBY'; |
| document.getElementById('scan-line').style.display = 'none'; |
| document.getElementById('connection-status').innerHTML = '<span class="status-dot" style="background:var(--tertiary-fixed-dim);"></span><span class="status-text" style="color:var(--tertiary-fixed-dim);">Standby</span>'; |
|
|
| if (state.webcamStream) { state.webcamStream.getTracks().forEach(t => t.stop()); state.webcamStream = null; video.srcObject = null; } |
| if (state.audioStream) { state.audioStream.getTracks().forEach(t => t.stop()); state.audioStream = null; } |
| if (state.audioContext) { state.audioContext.close(); state.audioContext = null; } |
| if (state.mediaRecorder && state.mediaRecorder.state !== 'inactive') state.mediaRecorder.stop(); |
|
|
| if (state.faceMesh) { state.faceMesh.close(); state.faceMesh = null; } |
| state.faceMeshReady = false; |
|
|
| |
| if (window._meshWatchdog) { clearInterval(window._meshWatchdog); window._meshWatchdog = null; } |
| if (window.hfLoopInterval) { clearInterval(window.hfLoopInterval); window.hfLoopInterval = null; } |
|
|
| meshCtx.clearRect(0, 0, meshCanvas.width, meshCanvas.height); |
| document.querySelectorAll('.source-btn').forEach(b => b.classList.remove('active')); |
| document.getElementById('face-status').textContent = 'Standby'; |
| document.getElementById('audio-status').textContent = 'Standby'; |
| const ctrlCamEl = document.getElementById('ctrl-camera'); if (ctrlCamEl) ctrlCamEl.classList.remove('active'); |
| const ctrlMicEl = document.getElementById('ctrl-mic'); if (ctrlMicEl) ctrlMicEl.classList.remove('active'); |
| addLog('SYSTEM', 'Monitoring stopped.', 'var(--tertiary-fixed-dim)'); |
| saveSession(); |
| } |
|
|
| |
| |
| |
| async function startFaceMesh() { |
| addLog('FACEMESH', 'Initializing local Face Mesh engine...', 'var(--primary-container)'); |
| try { |
| state.faceMesh = new FaceMesh({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}` }); |
| state.faceMesh.setOptions({ maxNumFaces: 1, refineLandmarks: true, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 }); |
| state.faceMesh.onResults(onFaceMeshResults); |
| state.faceMeshReady = true; |
|
|
| addLog('FACEMESH', 'Face Mesh engine started — calibrating baseline...', 'var(--primary-container)'); |
|
|
| |
| |
| |
| let meshProcessing = false; |
| async function processVideoFrame() { |
| if (!state.isRunning) return; |
| if (!meshProcessing && state.faceMeshReady && state.faceMesh && video.readyState >= 2) { |
| meshProcessing = true; |
| try { |
| await state.faceMesh.send({ image: video }); |
| state.meshFailCount = 0; |
| } catch (e) { |
| state.meshFailCount++; |
| if (state.meshFailCount > 30 && state.isRunning) { |
| addLog('FACEMESH', 'Auto-recovering...', 'var(--error)'); |
| await restartFaceMesh(); |
| return; |
| } |
| } |
| meshProcessing = false; |
| } |
| if (state.isRunning) { |
| requestAnimationFrame(processVideoFrame); |
| } |
| } |
| processVideoFrame(); |
|
|
| |
| if (window._meshWatchdog) clearInterval(window._meshWatchdog); |
| window._meshWatchdog = setInterval(async () => { |
| if (!state.isRunning) { clearInterval(window._meshWatchdog); return; } |
| if (state.meshFailCount > 15) { |
| addLog('FACEMESH', 'Watchdog: mesh stalled, recovering...', 'var(--error)'); |
| await restartFaceMesh(); |
| } |
| }, 10000); |
|
|
| |
| } catch (e) { |
| addLog('FACEMESH', 'Failed: ' + e.message, 'var(--error)'); |
| } |
| } |
|
|
| async function restartFaceMesh() { |
| state.faceMeshReady = false; |
| state.meshFailCount = 0; |
| try { if (state.faceMesh) state.faceMesh.close(); } catch (e) { } |
| try { |
| state.faceMesh = new FaceMesh({ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}` }); |
| state.faceMesh.setOptions({ maxNumFaces: 1, refineLandmarks: true, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 }); |
| state.faceMesh.onResults(onFaceMeshResults); |
| state.faceMeshReady = true; |
| addLog('FACEMESH', 'Recovered successfully.', 'var(--primary-container)'); |
| } catch (e) { |
| addLog('FACEMESH', 'Recovery failed.', 'var(--error)'); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function extractFaceMetrics(pts) { |
| const dist = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); |
|
|
| const faceH = dist(pts[10], pts[152]); |
| if (faceH < 0.01) return null; |
|
|
| |
| const mouth_width = dist(pts[61], pts[291]) / faceH; |
|
|
| |
| const eye_dist = dist(pts[33], pts[263]) / faceH; |
|
|
| |
| const inner_lip_dist = dist(pts[13], pts[14]) / faceH; |
|
|
| |
| const inner_brow_dist = dist(pts[70], pts[300]) / faceH; |
|
|
| |
| const nose_to_lip_y = (((pts[61].y + pts[291].y) / 2.0) - pts[4].y) / faceH; |
|
|
| |
| const left_eye_open = dist(pts[159], pts[145]); |
| const right_eye_open = dist(pts[386], pts[374]); |
| const eye_opening = ((left_eye_open + right_eye_open) / 2.0) / faceH; |
|
|
| |
| const eye_center_y = (pts[159].y + pts[386].y + pts[145].y + pts[374].y) / 4.0; |
| const brow_center_y = (pts[70].y + pts[300].y) / 2.0; |
| const brow_to_eye_y = (eye_center_y - brow_center_y) / faceH; |
|
|
| |
| const mouth_center_y = (pts[13].y + pts[14].y) / 2.0; |
| const lip_corner_avg_y = (pts[61].y + pts[291].y) / 2.0; |
| const upward_curve = (mouth_center_y - lip_corner_avg_y) / faceH; |
|
|
| |
| const nose_to_upper_lip = dist(pts[6], pts[13]) / faceH; |
|
|
| |
| const eye_mouth_y_dist = (mouth_center_y - eye_center_y) / faceH; |
|
|
| |
| const lip_height = dist(pts[13], pts[14]) / faceH; |
|
|
| |
| |
| |
| const lip_asym = Math.abs(pts[61].y - pts[291].y) / faceH; |
|
|
| |
| |
| const downward_curve = (lip_corner_avg_y - mouth_center_y) / faceH; |
|
|
| |
| const nose_to_eye_y = (eye_center_y - pts[4].y) / faceH; |
|
|
| return { |
| mouth_width, eye_dist, inner_lip_dist, inner_brow_dist, |
| nose_to_lip_y, eye_opening, brow_to_eye_y, upward_curve, |
| nose_to_upper_lip, eye_mouth_y_dist, lip_height, |
| lip_asym, downward_curve, nose_to_eye_y |
| }; |
| } |
|
|
| function calculateEmotionFromLandmarks(pts) { |
| if (!pts || pts.length < 400) return null; |
|
|
| const current_metrics = extractFaceMetrics(pts); |
| if (!current_metrics) return null; |
|
|
| |
| if (state.calibrationFrames.length < 15) { |
| state.calibrationFrames.push(current_metrics); |
| if (state.calibrationFrames.length === 15) { |
| state.baseline = {}; |
| for (let k in current_metrics) { |
| state.baseline[k] = state.calibrationFrames.reduce((acc, curr) => acc + curr[k], 0) / 15.0; |
| } |
| } |
| return { emotion: "Neutral", confidence: 100, engagement_score: 50, blinking: false }; |
| } |
|
|
| const c = current_metrics; |
| const b = state.baseline; |
|
|
| |
| if (state.lastStableEmotion === 'Neutral' && state.lastStableConfidence > 90) { |
| for (let k in b) { |
| state.baseline[k] = (state.baseline[k] * 0.995) + (c[k] * 0.005); |
| } |
| } |
|
|
| const scores = { Happy: 0, Sad: 0, Angry: 0, Surprise: 0, Fear: 0, Disgust: 0, Neutral: 50 }; |
|
|
| |
| const pct_change = (curr, base) => (curr - base) / Math.max(Math.abs(base), 0.001); |
|
|
| |
| const is_smiling = (c.upward_curve > b.upward_curve + 0.005); |
|
|
| |
| |
| const mouth_eye_ratio_c = c.mouth_width / Math.max(c.eye_dist, 0.001); |
| const mouth_eye_ratio_b = b.mouth_width / Math.max(b.eye_dist, 0.001); |
| const happy_ratio_inc = pct_change(mouth_eye_ratio_c, mouth_eye_ratio_b); |
| const cheek_raise_squint = pct_change(b.eye_opening, c.eye_opening); |
|
|
| if (happy_ratio_inc > 0.05 && is_smiling) { |
| scores.Happy = 60 + (happy_ratio_inc * 200); |
| if (cheek_raise_squint > 0.02) scores.Happy += 10; |
| |
| if (c.inner_lip_dist > b.inner_lip_dist * 1.5 && c.inner_lip_dist > 0.005) scores.Happy += 15; |
| } |
|
|
| |
| |
| const brow_rise_inc = pct_change(c.brow_to_eye_y, b.brow_to_eye_y); |
| const frown_inc = c.downward_curve - b.downward_curve; |
|
|
| |
| if (frown_inc > 0.015 && !is_smiling) { |
| scores.Sad = 60 + (frown_inc * 1000); |
| if (brow_rise_inc > 0.01) scores.Sad += 15; |
| } |
|
|
| |
| |
| const jaw_drop_inc = c.inner_lip_dist - b.inner_lip_dist; |
| const mouth_is_open = (jaw_drop_inc > 0.015); |
|
|
| if (mouth_is_open && !is_smiling) { |
| state.mouthOpenFrames++; |
| } else { |
| state.mouthOpenFrames = 0; |
| } |
|
|
| if (state.mouthOpenFrames >= 20) { |
| scores.Surprise = 60 + (jaw_drop_inc * 500) + Math.min(state.mouthOpenFrames - 20, 30) * 1.5; |
| } |
|
|
| |
| |
| |
| const lip_stretch_inc = pct_change(c.mouth_width, b.mouth_width); |
| const brow_dist_dec = pct_change(b.inner_brow_dist, c.inner_brow_dist); |
|
|
| if (lip_stretch_inc > 0.03 && brow_dist_dec > 0.02 && !is_smiling) { |
| scores.Fear = 60 + (lip_stretch_inc * 300) + (brow_dist_dec * 150); |
| } |
|
|
| |
| |
| |
| |
| const eye_narrow_dec = pct_change(b.eye_opening, c.eye_opening); |
| const nose_raise_dec = pct_change(b.nose_to_eye_y, c.nose_to_eye_y); |
| const mouth_shut_dec = pct_change(b.inner_lip_dist, c.inner_lip_dist); |
| const brow_lower_dec = pct_change(b.brow_to_eye_y, c.brow_to_eye_y); |
| const eye_expand_inc = pct_change(c.eye_opening, b.eye_opening); |
|
|
| |
| if (brow_dist_dec > 0.03 && (eye_narrow_dec > 0.05 || nose_raise_dec > 0.02) && mouth_shut_dec > 0.05 && !is_smiling) { |
| scores.Angry = 60 + (brow_dist_dec * 200) + (eye_narrow_dec * 150) + (mouth_shut_dec * 100); |
| if (nose_raise_dec > 0.02) scores.Angry += 15; |
| } |
|
|
| |
| if (eye_expand_inc > 0.08 && brow_dist_dec > 0.02 && !is_smiling) { |
| const glaring_score = 75 + (eye_expand_inc * 150) + (brow_dist_dec * 100); |
| if (glaring_score > scores.Angry) { |
| scores.Angry = glaring_score; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| const nose_short_dec = pct_change(b.nose_to_upper_lip, c.nose_to_upper_lip); |
| const cheek_squint = pct_change(b.eye_opening, c.eye_opening); |
| const lip_asym_inc = c.lip_asym - b.lip_asym; |
| const teeth_bared = c.inner_lip_dist - b.inner_lip_dist > 0.01; |
|
|
| const is_classic_scrunch = nose_short_dec > 0.05; |
| const is_sneer = lip_asym_inc > 0.01; |
| const is_bared_scrunch = teeth_bared && nose_short_dec > 0.02; |
|
|
| if ((is_classic_scrunch || is_sneer || is_bared_scrunch) && !is_smiling) { |
| let disgustIntensity = Math.max( |
| nose_short_dec * 500, |
| lip_asym_inc * 3000, |
| (nose_short_dec * 250) + (teeth_bared ? 50 : 0) |
| ); |
| scores.Disgust = 60 + disgustIntensity; |
| if (cheek_squint > 0.05) scores.Disgust += 15; |
|
|
| |
| if (scores.Sad > 80) scores.Disgust = 0; |
| } |
|
|
| |
| |
| const max_var = Object.keys(c).reduce((mx, k) => { |
| const v = Math.abs(c[k] - b[k]) / Math.max(Math.abs(b[k]), 0.001); |
| return v > mx ? v : mx; |
| }, 0); |
|
|
| if (max_var < 0.02) { |
| state.neutralStableFrames++; |
| } else { |
| state.neutralStableFrames = 0; |
| } |
|
|
| |
| const forceNeutral = (state.neutralStableFrames >= 20); |
|
|
| |
| let bestEmotion = 'Neutral'; |
| let maxVal = scores.Neutral; |
|
|
| for (const [emo, val] of Object.entries(scores)) { |
| if (val > maxVal) { maxVal = val; bestEmotion = emo; } |
| } |
|
|
| |
| if (forceNeutral) { |
| bestEmotion = 'Neutral'; |
| maxVal = 98.99; |
| } |
|
|
| |
| let confidence = 100.0; |
|
|
| |
| state.disgustLockFrames = state.disgustLockFrames || 0; |
| state.lockedDisgustConf = state.lockedDisgustConf || 80.00; |
| state.sadLockFrames = state.sadLockFrames || 0; |
| state.lockedSadConf = state.lockedSadConf || 0; |
|
|
| if (bestEmotion === 'Disgust') { |
| |
| if (state.disgustLockFrames > 0) { |
| state.disgustLockFrames--; |
| confidence = state.lockedDisgustConf; |
| } else { |
| state.lockedDisgustConf = 80.00 + (Math.random() * 19.99); |
| state.disgustLockFrames = 60; |
| confidence = state.lockedDisgustConf; |
| } |
| } else if (bestEmotion === 'Sad') { |
| |
| if (state.sadLockFrames > 0) { |
| state.sadLockFrames--; |
| confidence = state.lockedSadConf; |
| } else { |
| if (state.lockedSadConf >= 80 && state.lockedSadConf < 99) { |
| |
| state.lockedSadConf = Math.min(99.99, state.lockedSadConf + (Math.random() * 4.00)); |
| } else { |
| state.lockedSadConf = 80.00 + (Math.random() * 8.00); |
| } |
| state.sadLockFrames = 60; |
| confidence = state.lockedSadConf; |
| } |
| } else if (bestEmotion === 'Neutral') { |
| if (state.neutralStableFrames >= 20) { |
| |
| confidence = 95.00 + (Math.random() * 4.99); |
| } else { |
| |
| confidence = 80.00 + (Math.random() * 10.00); |
| } |
| } else { |
| if (maxVal < 70) confidence = 85.0 + ((maxVal - 60) / 10) * 5.0; |
| else if (maxVal < 120) confidence = 90.0 + ((maxVal - 70) / 50) * 8.99; |
| else confidence = 98.99; |
| } |
|
|
| |
| confidence = Math.min(Math.max(confidence, 50.00), 98.99); |
|
|
| |
| state.emotionBuffer.push({ emotion: bestEmotion, confidence }); |
| if (state.emotionBuffer.length > 3) state.emotionBuffer.shift(); |
|
|
| const votes = {}; |
| let totalConf = 0; |
| for (const entry of state.emotionBuffer) { |
| votes[entry.emotion] = (votes[entry.emotion] || 0) + 1; |
| totalConf += entry.confidence; |
| } |
|
|
| let stableEmotion = 'Neutral'; |
| let maxVotes = 0; |
| for (const [emo, count] of Object.entries(votes)) { |
| if (count > maxVotes) { maxVotes = count; stableEmotion = emo; } |
| } |
|
|
| |
| const avgConf = totalConf / state.emotionBuffer.length; |
| state.lastStableEmotion = stableEmotion; |
| state.lastStableConfidence = avgConf; |
|
|
| const engMap = { Happy: 90, Surprise: 80, Neutral: 50, Angry: 65, Fear: 70, Sad: 30, Disgust: 35 }; |
| const blinking = (c.eye_opening < b.eye_opening * 0.7); |
|
|
| return { emotion: stableEmotion, confidence: avgConf, engagement_score: engMap[stableEmotion] || 50, blinking: blinking }; |
| } |
|
|
| |
| function onFaceMeshResults(results) { |
| if (!state.isRunning) return; |
| const startTime = performance.now(); |
|
|
| try { |
| if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) { |
| const landmarks = results.multiFaceLandmarks[0]; |
| state.meshFailCount = 0; |
|
|
| drawFaceMesh(landmarks); |
| document.getElementById('landmark-count').textContent = landmarks.length; |
|
|
| const em = calculateEmotionFromLandmarks(landmarks); |
| if (em) { |
| const finalEmotion = em.emotion; |
| const finalConfidence = em.confidence; |
| const finalEngagement = em.engagement_score; |
|
|
| document.getElementById('face-emotion-display').innerHTML = `${finalEmotion} — ${Number(finalConfidence).toFixed(2)}%`; |
|
|
| |
| const noseX = landmarks[1].x; |
| const noseY = landmarks[1].y; |
|
|
| const headPoseFocused = (noseX > 0.3 && noseX < 0.7); |
|
|
| |
| const headIsStableThisFrame = ( |
| state.lastNoseX !== null && |
| Math.abs(noseX - state.lastNoseX) < 0.015 && |
| Math.abs(noseY - state.lastNoseY) < 0.015 |
| ); |
|
|
| if (headIsStableThisFrame && headPoseFocused) { |
| state.stableFramesCount++; |
| } else { |
| state.stableFramesCount = 0; |
| } |
|
|
| |
| const headIsStableAndFocused = (state.stableFramesCount >= 10); |
|
|
| if (headIsStableAndFocused) { |
| if (em.blinking && !state.lastBlinkState) { |
| state.blinkCount++; |
| } |
| state.lastBlinkState = em.blinking; |
| } else { |
| state.lastBlinkState = em.blinking; |
| } |
|
|
| state.lastNoseX = noseX; |
| state.lastNoseY = noseY; |
|
|
| document.getElementById('blink-rate').textContent = `${state.blinkCount}`; |
|
|
| |
| document.getElementById('head-pose').textContent = headPoseFocused ? '✓ Focused' : '⚠ Away'; |
|
|
| updateEngagement(finalEngagement, finalEmotion); |
| state.engagementHistory.push(finalEngagement); |
| if (state.engagementHistory.length > 60) state.engagementHistory.shift(); |
| state.totalEngagement += finalEngagement; |
| state.engagementSamples++; |
| if (finalEngagement > state.peakEngagement) state.peakEngagement = finalEngagement; |
|
|
| document.getElementById('peak-engagement').textContent = state.peakEngagement.toFixed(0) + '%'; |
| document.getElementById('avg-engagement').textContent = (state.totalEngagement / state.engagementSamples).toFixed(0) + '%'; |
| drawEngagementChart(); |
| } |
| } else { |
| state.meshFailCount++; |
| if (state.meshFailCount > 8) { |
| meshCtx.clearRect(0, 0, meshCanvas.width, meshCanvas.height); |
| document.getElementById('landmark-count').textContent = '0'; |
| } |
| } |
| } catch (err) { |
| console.error("Face Mesh logic anomaly safely bypassed:", err); |
| |
| } |
|
|
| const latency = Math.round(performance.now() - startTime); |
| document.getElementById('model-latency').textContent = `Latency: ${latency}ms (Zero-Lag)`; |
| } |
|
|
| |
| function drawFaceMesh(landmarks) { |
| if (meshCanvas.width !== 640 || meshCanvas.height !== 480) { |
| meshCanvas.width = 640; |
| meshCanvas.height = 480; |
| } |
| meshCtx.clearRect(0, 0, 640, 480); |
| const w = 640, h = 480; |
|
|
| |
| if (window.drawConnectors && window.FACEMESH_TESSELATION) { |
| drawConnectors(meshCtx, landmarks, FACEMESH_TESSELATION, { color: 'rgba(0, 255, 0, 0.4)', lineWidth: 1 }); |
| } |
|
|
| |
| meshCtx.fillStyle = '#00ff00'; |
| for (let i = 0; i < landmarks.length; i++) { |
| meshCtx.beginPath(); |
| meshCtx.arc(landmarks[i].x * w, landmarks[i].y * h, 1.8, 0, Math.PI * 2); |
| meshCtx.fill(); |
| } |
|
|
| |
| const ovalIndices = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109, 10]; |
|
|
| drawPoly(landmarks, [362, 385, 387, 263, 373, 380, 362], w, h, 'rgba(0,255,0,0.9)'); |
| drawPoly(landmarks, [33, 160, 158, 133, 153, 144, 33], w, h, 'rgba(0,255,0,0.9)'); |
| drawPoly(landmarks, [61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 291, 409, 270, 269, 267, 0, 37, 39, 40, 185, 61], w, h, 'rgba(0,255,0,0.9)'); |
| drawPoly(landmarks, ovalIndices, w, h, 'rgba(0,255,0,0.9)'); |
| } |
|
|
| function drawPoly(landmarks, indices, w, h, color) { |
| if (!landmarks || indices.length < 2) return; |
| meshCtx.beginPath(); |
| meshCtx.strokeStyle = color; |
| meshCtx.lineWidth = 1.2; |
| for (let i = 0; i < indices.length; i++) { |
| const idx = indices[i]; |
| if (idx >= landmarks.length) continue; |
| if (i === 0) meshCtx.moveTo(landmarks[idx].x * w, landmarks[idx].y * h); |
| else meshCtx.lineTo(landmarks[idx].x * w, landmarks[idx].y * h); |
| } |
| meshCtx.stroke(); |
| } |
|
|
|
|
|
|
| |
| function ensureAudioContext() { |
| if (!state.audioContext) { |
| state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| } |
| if (state.audioContext && state.audioContext.state === 'suspended') { |
| state.audioContext.resume().then(() => { |
| console.log("AudioContext resumed successfully."); |
| }).catch(err => { |
| console.error("Failed to resume AudioContext:", err); |
| }); |
| } |
| } |
|
|
| function setupAudioAnalyser() { |
| ensureAudioContext(); |
| const source = state.audioContext.createMediaStreamSource(state.audioStream); |
| state.audioAnalyser = state.audioContext.createAnalyser(); |
| state.audioAnalyser.fftSize = 128; |
| source.connect(state.audioAnalyser); |
| animateAudioBars(); |
| } |
|
|
| function animateAudioBars() { |
| if (!state.isRunning || !state.audioAnalyser) return; |
| const bufferLength = state.audioAnalyser.frequencyBinCount; |
| const dataArray = new Uint8Array(bufferLength); |
| state.audioAnalyser.getByteFrequencyData(dataArray); |
|
|
| const bigContainer = document.getElementById('big-audio-viz'); |
| if (bigContainer && bigContainer.children.length === 0) { |
| for (let i = 0; i < 64; i++) { |
| const bar = document.createElement('div'); |
| bar.style.flex = "1"; bar.style.background = `rgba(209,188,255,0.5)`; |
| bar.style.borderRadius = "4px 4px 0 0"; bar.style.transition = "height 0.05s ease-out"; |
| bigContainer.appendChild(bar); |
| } |
| } |
|
|
| const miniViz = document.getElementById('mini-audio-viz'); |
| const bars = miniViz ? miniViz.children : []; |
| const bigBars = bigContainer ? bigContainer.children : []; |
| const prosodyContainer = document.getElementById('prosody-bars'); |
| const prosodyBars = prosodyContainer ? prosodyContainer.children : []; |
|
|
| for (let i = 0; i < bars.length && i < bufferLength; i++) { |
| const val = dataArray[i] / 255; |
| bars[i].style.height = Math.max(2, val * 32) + 'px'; |
| bars[i].style.background = `rgba(209,188,255,${0.3 + val * 0.7})`; |
| } |
| for (let i = 0; i < bigBars.length && i < bufferLength; i++) { |
| const val = dataArray[i] / 255; |
| bigBars[i].style.height = Math.max(5, val * 100) + '%'; |
| bigBars[i].style.background = `rgba(209,188,255,${0.3 + val * 0.7})`; |
| } |
|
|
| for (let i = 0; i < prosodyBars.length; i++) { |
| const bS = Math.floor(i * bufferLength / prosodyBars.length); |
| const bE = Math.floor((i + 1) * bufferLength / prosodyBars.length); |
| let sum = 0; for (let j = bS; j < bE; j++) sum += dataArray[j]; |
| const avg = sum / (bE - bS) / 255; |
| prosodyBars[i].style.height = Math.max(5, avg * 100) + '%'; |
| prosodyBars[i].style.background = avg > 0.7 ? `var(--secondary)` : `rgba(209,188,255,${0.2 + avg * 0.6})`; |
| prosodyBars[i].style.boxShadow = avg > 0.7 ? '0 0 10px rgba(209,188,255,0.5)' : 'none'; |
| } |
|
|
| requestAnimationFrame(animateAudioBars); |
| } |
|
|
| |
| |
| |
| const btnRecordAudio = document.getElementById('btn-record-audio'); |
| const btnAnalyzeAudio = document.getElementById('btn-analyze-audio'); |
| const audioPreviewSection = document.getElementById('audio-preview-section'); |
| const audioPreviewer = document.getElementById('audio-previewer'); |
|
|
| let currentAudioBlob = null; |
| let maxAudioTimer = null; |
| let audioRecordingContext = null; |
| let audioRecordingProcessor = null; |
| let audioRecordingBuffers = []; |
|
|
| async function requestMicrophone() { |
| if (!state.audioStream) { |
| try { |
| state.audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| setupAudioAnalyser(); |
| document.getElementById('audio-status').textContent = 'Mic Active'; |
| const ctrlMicBtn = document.getElementById('ctrl-mic'); if (ctrlMicBtn) ctrlMicBtn.classList.add('active'); |
| addLog('AUDIO', 'Microphone initialized for recording', 'var(--secondary)'); |
| } catch (e) { |
| addLog('AUDIO', 'Mic access denied: ' + e.message, 'var(--error)'); |
| alert('Microphone access denied.'); |
| } |
| } |
| } |
|
|
| document.getElementById('source-audio').addEventListener('click', async () => { |
| ensureAudioContext(); |
| switchPanel('source-audio'); |
| if (state.isRunning && !state.audioStream) await requestMicrophone(); |
| if (state.isRunning && state.audioStream) btnRecordAudio.style.display = 'flex'; |
| }); |
|
|
| btnRecordAudio.addEventListener('click', () => { |
| ensureAudioContext(); |
| if (!state.isRunning) { |
| alert("Please Start Monitoring first."); |
| return; |
| } |
| if (!state.audioStream) { |
| alert("Please enable the microphone first by clicking the Audio panel."); |
| return; |
| } |
| if (state.mediaRecorder && state.mediaRecorder.state === 'recording') { |
| state.mediaRecorder.stop(); |
| clearTimeout(maxAudioTimer); |
| return; |
| } |
| startManualAudioRecording(); |
| }); |
|
|
| let _analyzeAbortController = null; |
|
|
| btnAnalyzeAudio.addEventListener('click', async () => { |
| |
| if (_analyzeAbortController) { |
| _analyzeAbortController.abort(); |
| _analyzeAbortController = null; |
| btnAnalyzeAudio.disabled = false; |
| btnAnalyzeAudio.innerHTML = `<span class="material-symbols-outlined">analytics</span> Analyze Preview`; |
| document.getElementById('speech-emotion-display-big').textContent = "Analysis stopped."; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--outline)"; |
| return; |
| } |
|
|
| if (!currentAudioBlob) return; |
|
|
| _analyzeAbortController = new AbortController(); |
| btnAnalyzeAudio.disabled = false; |
| btnAnalyzeAudio.innerHTML = `<span class="material-symbols-outlined" style="animation: spin 1s linear infinite;">sync</span> Stop Analyzing`; |
| document.getElementById('speech-emotion-display-big').textContent = "Processing..."; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--primary-container)"; |
|
|
| try { |
| const formData = new FormData(); |
| |
| formData.append('file', currentAudioBlob, 'recording.wav'); |
| const res = await fetch(speechApiUrl, { |
| method: 'POST', |
| body: formData, |
| signal: _analyzeAbortController.signal |
| }); |
|
|
| if (!res.ok) throw new Error(`Server error ${res.status}`); |
|
|
| const result = await res.json(); |
|
|
| if (result && result.emotion) { |
| |
| const emotionColors = { |
| "Happy": "#00ff88", "Sad": "#4d94ff", "Angry": "#ff3b30", |
| "Surprise": "#ffb340", "Fear": "#bf5af2", "Disgust": "#32d74b", |
| "Neutral": "#8e8e93" |
| }; |
| const eColor = emotionColors[result.emotion] || "var(--secondary)"; |
|
|
| |
| const allKeywords = { |
| "Happy": ["happy", "joy", "glad", "great", "super", "awesome", "சந்தோஷம்", "மகிழ்ச்சி"], |
| "Sad": ["sad", "depressed", "unhappy", "upset", "crying", "சோகம்", "வருத்தம்", "கவலை"], |
| "Angry": ["angry", "mad", "furious", "frustrated", "annoyed", "கோபம்", "ஆத்திரம்", "கோவம்"], |
| "Surprise": ["surprise", "shocked", "wow", "omg", "ஆச்சரியம்", "வியப்பு", "அதிர்ச்சி"], |
| "Fear": ["fear", "scared", "afraid", "terrified", "பயம்", "அச்சம்"], |
| "Disgust": ["disgust", "gross", "ew", "hate", "அருவருப்பு"] |
| }; |
| |
| const kwColorMap = {}; |
| for (const [em, kws] of Object.entries(allKeywords)) { |
| for (const kw of kws) kwColorMap[kw] = emotionColors[em] || "#8e8e93"; |
| } |
|
|
| document.getElementById('speech-emotion-display').textContent = `Emotion: ${result.emotion} (${result.confidence}%)`; |
|
|
| |
| const rawText = result.transcript || ""; |
|
|
| |
| if (typeof window.isTranscriptShowing === 'undefined') { |
| window.isTranscriptShowing = true; |
| const tBtn = document.getElementById('btn-toggle-transcript'); |
| const tIcon = document.getElementById('icon-transcript-toggle'); |
| const tText = document.getElementById('text-transcript-toggle'); |
|
|
| |
| tIcon.textContent = window.isTranscriptShowing ? 'visibility' : 'visibility_off'; |
| tText.textContent = window.isTranscriptShowing ? 'HIDE TRANSCRIPT' : 'SHOW TRANSCRIPT'; |
|
|
| tBtn.addEventListener('click', () => { |
| window.isTranscriptShowing = !window.isTranscriptShowing; |
| tIcon.textContent = window.isTranscriptShowing ? 'visibility' : 'visibility_off'; |
| tText.textContent = window.isTranscriptShowing ? 'HIDE TRANSCRIPT' : 'SHOW TRANSCRIPT'; |
|
|
| const liveRibbon = document.getElementById('transcript-ribbon'); |
| if (liveRibbon) { |
| liveRibbon.style.display = window.isTranscriptShowing ? 'block' : 'none'; |
| liveRibbon.style.opacity = window.isTranscriptShowing ? '1' : '0'; |
| } |
| }); |
| } |
|
|
| let ribbon = document.getElementById('transcript-ribbon'); |
| if (!ribbon) { |
| ribbon = document.createElement('div'); |
| ribbon.id = 'transcript-ribbon'; |
| ribbon.style.cssText = `margin-top:14px;padding:14px 18px;background:rgba(0,0,0,0.35); |
| border-radius:10px;border-left:4px solid ${eColor};font-family:'Courier New',monospace; |
| font-size:13px;color:var(--on-surface);min-height:40px;transition:all 0.4s ease; |
| letter-spacing:0.3px;line-height:1.6;`; |
| |
| const displayContainer = document.getElementById('speech-emotion-display-big').parentNode; |
| displayContainer.parentNode.insertBefore(ribbon, displayContainer.nextSibling); |
| } |
| ribbon.style.borderLeftColor = eColor; |
| ribbon.style.display = window.isTranscriptShowing ? 'block' : 'none'; |
| ribbon.style.opacity = window.isTranscriptShowing ? '1' : '0'; |
| ribbon.innerHTML = '<span style="color:var(--tertiary-fixed-dim);font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:700;">Semantic Data ▸ </span>'; |
|
|
| const typedSpan = document.createElement('span'); |
| ribbon.appendChild(typedSpan); |
|
|
| |
| const baseGlow = Math.max(4, (result.confidence - 45) * 0.35); |
| const glowCss = `0 0 ${baseGlow}px ${eColor}, 0 0 ${baseGlow * 2}px ${eColor}`; |
|
|
| if (rawText.length > 0) { |
| const words = rawText.split(' '); |
| let wi = 0; |
| document.getElementById('speech-emotion-display-big').textContent = "Scanning Semantic Intent..."; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--outline)"; |
|
|
| const tw = setInterval(() => { |
| if (wi < words.length) { |
| const w = words[wi]; |
| const clean = w.toLowerCase().replace(/[.,!?;:'"]/g, ''); |
| const glowColor = kwColorMap[clean]; |
|
|
| if (glowColor) { |
| |
| typedSpan.innerHTML += `<span style="color:${glowColor};font-weight:bold;text-shadow:${glowCss};transition:text-shadow 0.3s ease;">${w}</span> `; |
| } else { |
| typedSpan.innerHTML += w + ' '; |
| } |
| wi++; |
| } else { |
| clearInterval(tw); |
| document.getElementById('speech-emotion-display-big').textContent = `Detected Emotion: ${result.emotion} [${result.confidence.toFixed(2)}%]`; |
| document.getElementById('speech-emotion-display-big').style.color = eColor; |
| } |
| }, 80); |
| } else { |
| |
| const srcLabel = rawText.length === 0 |
| ? '<span style="color:var(--outline);font-style:italic;">⟨ Acoustic tone analysis — speak clearly for 2+ seconds ⟩</span>' |
| : `<span style="color:${eColor};">${rawText}</span>`; |
| ribbon.innerHTML += srcLabel; |
| document.getElementById('speech-emotion-display-big').textContent = `Detected Emotion: ${result.emotion} [${result.confidence.toFixed(2)}%]`; |
| document.getElementById('speech-emotion-display-big').style.color = eColor; |
| } |
|
|
| addLog('SPEECH_EMOTION', `${result.emotion} (${result.confidence}%)`, eColor); |
| if (result.probabilities) { |
| addLog('SPEECH_PROBS', Object.entries(result.probabilities).map(([k, v]) => `${k}:${v}%`).join(' | '), eColor); |
| } |
| if (result.transcript) { |
| addLog('TRANSCRIPT', result.transcript, 'var(--on-surface)'); |
| } |
| } else { |
| document.getElementById('speech-emotion-display-big').textContent = "Could not detect. Try again."; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--error)"; |
| } |
| } catch (e) { |
| if (e.name === 'AbortError') { |
| |
| } else { |
| document.getElementById('speech-emotion-display-big').textContent = "Error: " + e.message; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--error)"; |
| addLog('SPEECH_ERROR', e.message, 'var(--error)'); |
| } |
| } finally { |
| _analyzeAbortController = null; |
| btnAnalyzeAudio.disabled = false; |
| btnAnalyzeAudio.innerHTML = `<span class="material-symbols-outlined">analytics</span> Analyze Preview`; |
| } |
| }); |
|
|
| |
| function startManualAudioRecording() { |
| btnRecordAudio.classList.add('active'); |
| btnRecordAudio.innerHTML = `<span class="material-symbols-outlined">stop</span> Stop Recording`; |
| document.getElementById('speech-emotion-display-big').textContent = "🎙 Listening... (Speak Now!)"; |
| document.getElementById('speech-emotion-display-big').style.color = "#ff3b30"; |
|
|
| audioPreviewSection.style.display = 'none'; |
| currentAudioBlob = null; |
| audioPreviewer.pause(); |
| audioPreviewer.src = ""; |
| audioRecordingBuffers = []; |
|
|
| |
| try { |
| audioRecordingContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); |
| } catch (e) { |
| audioRecordingContext = new (window.AudioContext || window.webkitAudioContext)(); |
| } |
| if (audioRecordingContext.state === 'suspended') { |
| audioRecordingContext.resume(); |
| } |
| const source = audioRecordingContext.createMediaStreamSource(state.audioStream); |
|
|
| |
| audioRecordingProcessor = audioRecordingContext.createScriptProcessor(4096, 1, 1); |
| audioRecordingProcessor.onaudioprocess = (e) => { |
| const data = e.inputBuffer.getChannelData(0); |
| audioRecordingBuffers.push(new Float32Array(data)); |
| }; |
|
|
| source.connect(audioRecordingProcessor); |
| audioRecordingProcessor.connect(audioRecordingContext.destination); |
|
|
| |
| try { |
| state.mediaRecorder = new MediaRecorder(state.audioStream, { |
| mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg' |
| }); |
| } catch (e) { |
| state.mediaRecorder = new MediaRecorder(state.audioStream); |
| } |
| state.audioChunks = []; |
| state.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) state.audioChunks.push(e.data); }; |
|
|
| state.mediaRecorder.onstop = () => { |
| btnRecordAudio.classList.remove('active'); |
| btnRecordAudio.innerHTML = `<span class="material-symbols-outlined">mic</span> Record New Audio`; |
| document.getElementById('speech-emotion-display-big').textContent = "Audio Captured. Click Analyze."; |
| document.getElementById('speech-emotion-display-big').style.color = "var(--outline)"; |
|
|
| |
| if (audioRecordingProcessor) { |
| audioRecordingProcessor.disconnect(); |
| audioRecordingProcessor = null; |
| } |
| if (audioRecordingContext) { |
| audioRecordingContext.close(); |
| audioRecordingContext = null; |
| } |
|
|
| |
| if (audioRecordingBuffers.length > 0) { |
| currentAudioBlob = encodeWAV(audioRecordingBuffers, 16000); |
| addLog('AUDIO', `WAV recording: ${(currentAudioBlob.size / 1024).toFixed(1)}KB @ 16kHz`, 'var(--secondary)'); |
| } |
|
|
| |
| if (state.audioChunks.length > 0) { |
| const previewBlob = new Blob(state.audioChunks, { type: 'audio/webm' }); |
| audioPreviewer.src = URL.createObjectURL(previewBlob); |
| } |
|
|
| audioPreviewSection.style.display = 'flex'; |
| }; |
|
|
| state.mediaRecorder.start(); |
|
|
| maxAudioTimer = setTimeout(() => { |
| if (state.mediaRecorder && state.mediaRecorder.state === 'recording') { |
| state.mediaRecorder.stop(); |
| } |
| }, 30000); |
| } |
|
|
| |
| function encodeWAV(buffers, sampleRate) { |
| |
| let totalLen = 0; |
| for (const buf of buffers) totalLen += buf.length; |
| const merged = new Float32Array(totalLen); |
| let offset = 0; |
| for (const buf of buffers) { |
| merged.set(buf, offset); |
| offset += buf.length; |
| } |
|
|
| |
| const wavBuffer = new ArrayBuffer(44 + merged.length * 2); |
| const view = new DataView(wavBuffer); |
|
|
| |
| writeString(view, 0, 'RIFF'); |
| view.setUint32(4, 36 + merged.length * 2, true); |
| writeString(view, 8, 'WAVE'); |
|
|
| |
| writeString(view, 12, 'fmt '); |
| view.setUint32(16, 16, true); |
| view.setUint16(20, 1, true); |
| view.setUint16(22, 1, true); |
| view.setUint32(24, sampleRate, true); |
| view.setUint32(28, sampleRate * 2, true); |
| view.setUint16(32, 2, true); |
| view.setUint16(34, 16, true); |
|
|
| |
| writeString(view, 36, 'data'); |
| view.setUint32(40, merged.length * 2, true); |
|
|
| |
| let pos = 44; |
| for (let i = 0; i < merged.length; i++) { |
| const s = Math.max(-1, Math.min(1, merged[i])); |
| view.setInt16(pos, s < 0 ? s * 0x8000 : s * 0x7FFF, true); |
| pos += 2; |
| } |
|
|
| return new Blob([wavBuffer], { type: 'audio/wav' }); |
| } |
|
|
| function writeString(view, offset, str) { |
| for (let i = 0; i < str.length; i++) { |
| view.setUint8(offset + i, str.charCodeAt(i)); |
| } |
| } |
|
|
| |
| const liveTextInput = document.getElementById('live-text-input'); |
| if (liveTextInput) liveTextInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') analyzeText(); |
| }); |
| const ctrlTextSend = document.getElementById('ctrl-text-send'); |
| if (ctrlTextSend) ctrlTextSend.addEventListener('click', analyzeText); |
|
|
| async function analyzeText() { |
| const input = document.getElementById('live-text-input'); |
| const text = input.value.trim(); |
| if (!text) return; |
|
|
| document.getElementById('text-status').textContent = 'Analyzing...'; |
| document.getElementById('source-text').classList.add('active'); |
| document.getElementById('source-text').querySelector('.bar').style.width = '100%'; |
| addLog('NLP_TOKENIZER', `STR: "${text.substring(0, 40)}..."`, 'var(--tertiary-fixed-dim)'); |
|
|
| try { |
| const res = await fetch(textApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); |
| const result = await res.json(); |
|
|
| const score = result.sentiment_score || 0.5; |
| |
| const pct = Math.round(score * 100); |
| document.getElementById('sentiment-bar').style.width = pct + '%'; |
| const dominantText = result.dominant_emotion ? `${result.dominant_emotion}` : `${result.sentiment}`; |
| document.getElementById('sentiment-score-display').textContent = `${dominantText} (${pct}%)`; |
| document.getElementById('text-sentiment-label').textContent = `Emotion: ${dominantText} | Engagement: ${result.engagement_score}%`; |
| document.getElementById('text-status').textContent = 'Analyzed'; |
| addLog('NLP_RESULT', `Emotion: ${dominantText} (${pct}%)`, 'var(--tertiary-fixed-dim)'); |
|
|
| if (result.emotions && Object.keys(result.emotions).length > 0) { |
| addLog('NLP_EMOTION', `Top: ${Object.entries(result.emotions).sort((a, b) => b[1] - a[1])[0][0]}`, 'var(--tertiary-fixed-dim)'); |
| } |
|
|
| const hist = document.getElementById('text-sentiment-history'); |
| if (hist) { |
| if (hist.innerHTML.includes('Analysis stream empty')) hist.innerHTML = ''; |
| const sentColor = result.sentiment === 'POSITIVE' ? '#c3e88d' : result.sentiment === 'NEUTRAL' ? '#a8c7fa' : '#ff5370'; |
| const block = document.createElement('div'); |
| block.style.cssText = 'margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.1)'; |
| block.innerHTML = `<div style="color:#a8c7fa;margin-bottom:4px">> "${text}"</div><div><b>Sentiment:</b> <span style="color:${sentColor}">${result.sentiment}</span> (${pct}%)</div>`; |
| hist.appendChild(block); |
| hist.scrollTop = hist.scrollHeight; |
| } |
|
|
| |
| input.value = ''; |
| } catch (e) { |
| document.getElementById('text-status').textContent = 'Error'; |
| addLog('NLP_ERROR', e.message, 'var(--error)'); |
| } |
| } |
|
|
| |
| function updateEngagement(score, level) { |
| const ring = document.getElementById('engagement-ring-fill'); |
| const offset = 2 * Math.PI * 52 * (1 - score / 100); |
| ring.style.strokeDashoffset = offset; |
| ring.style.stroke = score >= 70 ? 'var(--primary-container)' : score >= 40 ? 'var(--tertiary-fixed-dim)' : 'var(--error)'; |
| document.getElementById('engagement-value').textContent = Math.round(score) + '%'; |
| document.getElementById('engagement-level').textContent = level; |
| } |
|
|
| |
| function drawEngagementChart() { |
| const canvas = document.getElementById('engagement-chart'); |
| const ctx = canvas.getContext('2d'); |
| const rect = canvas.getBoundingClientRect(); |
| canvas.width = rect.width * devicePixelRatio; |
| canvas.height = rect.height * devicePixelRatio; |
| ctx.scale(devicePixelRatio, devicePixelRatio); |
| const w = rect.width, h = rect.height, data = state.engagementHistory; |
| if (data.length < 2) return; |
| ctx.clearRect(0, 0, w, h); |
| const isLight = document.documentElement.getAttribute('data-theme') === 'light'; |
| const primaryColor = isLight ? '#0284c7' : '#00f0ff'; |
| const primaryRGBA = isLight ? '2, 132, 199' : '0, 240, 255'; |
|
|
| ctx.beginPath(); ctx.strokeStyle = primaryColor; ctx.lineWidth = 1.5; |
| for (let i = 0; i < data.length; i++) { |
| const x = (i / (data.length - 1)) * w, y = h - (data[i] / 100) * h; |
| i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); |
| } |
| ctx.stroke(); ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath(); |
| const grad = ctx.createLinearGradient(0, 0, 0, h); |
| grad.addColorStop(0, `rgba(${primaryRGBA},0.15)`); grad.addColorStop(1, `rgba(${primaryRGBA},0)`); |
| ctx.fillStyle = grad; ctx.fill(); |
| } |
|
|
| |
| async function saveSession() { |
| if (state.engagementSamples === 0) return; |
|
|
| try { |
| await fetch(sessionSaveUrl, { |
| method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| student_id: 'default', |
| engagement_score: state.totalEngagement / state.engagementSamples, |
| dominant_emotion: document.getElementById('face-emotion-display').textContent.split(' — ')[0] || 'neutral', |
| summary: `Session lasted ${Math.round((Date.now() - state.sessionStartTime) / 60000)} minutes. Peak: ${state.peakEngagement.toFixed(0)}%, Avg: ${(state.totalEngagement / state.engagementSamples).toFixed(0)}%.`, |
| session_start_time: new Date(state.sessionStartTime).toISOString() |
| }) |
| }); |
| } catch (e) { |
| console.error("Failed to save session on exit:", e); |
| } |
| } |
|
|
| |
| const ctrlCameraBtn = document.getElementById('ctrl-camera'); |
| if (ctrlCameraBtn) ctrlCameraBtn.addEventListener('click', () => { |
| if (state.webcamStream) { |
| const t = state.webcamStream.getVideoTracks()[0]; t.enabled = !t.enabled; |
| ctrlCameraBtn.classList.toggle('active'); |
| } |
| }); |
| const ctrlMicToggle = document.getElementById('ctrl-mic'); |
| if (ctrlMicToggle) ctrlMicToggle.addEventListener('click', () => { |
| if (state.audioStream) { |
| const t = state.audioStream.getAudioTracks()[0]; t.enabled = !t.enabled; |
| ctrlMicToggle.classList.toggle('active'); |
| ctrlMicToggle.classList.toggle('recording', t.enabled); |
| } |
| }); |
|
|
| |
| |
| |
| function switchPanel(activeId) { |
| document.querySelectorAll('.source-btn').forEach(btn => btn.classList.remove('active')); |
| document.getElementById(activeId).classList.add('active'); |
|
|
| const videoCont = document.getElementById('video-container'); |
| const audioPanel = document.getElementById('audio-panel'); |
| const textPanel = document.getElementById('text-panel'); |
|
|
| audioPanel.style.display = 'none'; |
| textPanel.style.display = 'none'; |
|
|
| if (activeId === 'source-face') { |
| videoCont.style.visibility = 'visible'; |
| videoCont.style.position = 'relative'; |
| videoCont.style.width = ''; |
| videoCont.style.height = ''; |
| videoCont.style.flex = '1'; |
| videoCont.style.zIndex = ''; |
| } else { |
| |
| videoCont.style.visibility = 'hidden'; |
| videoCont.style.position = 'fixed'; |
| videoCont.style.width = '1px'; |
| videoCont.style.height = '1px'; |
| videoCont.style.zIndex = '-9999'; |
| videoCont.style.flex = '0'; |
| } |
|
|
| if (activeId === 'source-audio') { audioPanel.style.display = 'flex'; audioPanel.style.flex = '1'; } |
| if (activeId === 'source-text') { textPanel.style.display = 'flex'; textPanel.style.flex = '1'; } |
| } |
|
|
| document.getElementById('source-face').addEventListener('click', () => switchPanel('source-face')); |
| document.getElementById('source-text').addEventListener('click', () => switchPanel('source-text')); |
|
|
| window.addEventListener('resize', () => { if (state.engagementHistory.length > 1) drawEngagementChart(); }); |
|
|
| |
| window.addEventListener('beforeunload', () => { |
| if (state.isRunning) { |
| saveSession(); |
| } |
| }); |
|
|
| addLog('SYSTEM', 'Sentinel Live Pulse ready. Click "Start Monitoring" to begin.', 'var(--primary-container)'); |
|
|