/** * Proctoring System - Camera and Audio Monitoring * - Face detection * - Looking away detection * - Audio monitoring * - Warning system (2 warnings max) */ class ProctoringSystem { constructor() { this.isActive = false; this.stream = null; this.videoElement = null; this.lookAwayCount = 0; this.maxLookAwayWarnings = 2; this.lookAwayTimer = null; this.lookAwayThreshold = 3000; // 3 seconds this.faceDetectionInterval = null; this.lastFaceDetected = Date.now(); this.noFaceDetectedTime = 0; this.multipleFacesCount = 0; // BlazeFace model this.faceDetectionModel = null; // Audio monitoring this.audioContext = null; this.analyser = null; this.audioViolationCount = 0; this.maxAudioViolations = 2; this.audioThreshold = 30; // Lower threshold for better detection (0-100) this.suspiciousAudioDuration = 0; this.audioCheckInterval = null; } async startProctoring() { try { // Load face detection model console.log('Loading face detection model...'); this.faceDetectionModel = await blazeface.load(); console.log('Face detection model loaded'); // Request camera and microphone access this.stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }); // Create video element this.createVideoElement(); this.videoElement.srcObject = this.stream; await this.videoElement.play(); this.isActive = true; // Start face detection this.startFaceDetection(); // Start audio monitoring this.startAudioMonitoring(); // Log proctoring start this.logProctoringEvent('proctoring_started', { timestamp: new Date().toISOString() }); return true; } catch (error) { console.error('Proctoring error:', error); this.showProctoringError(error); return false; } } createVideoElement() { // Create video preview const videoContainer = document.createElement('div'); videoContainer.id = 'proctoringVideo'; videoContainer.style.cssText = ` position: fixed; bottom: 20px; right: 20px; width: 200px; height: 150px; border: 2px solid var(--color-primary); border-radius: var(--radius-lg); overflow: hidden; z-index: 9998; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); `; this.videoElement = document.createElement('video'); this.videoElement.style.cssText = ` width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); `; this.videoElement.autoplay = true; this.videoElement.muted = true; const label = document.createElement('div'); label.style.cssText = ` position: absolute; top: 5px; left: 5px; background: rgba(0, 0, 0, 0.7); color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; display: flex; align-items: center; gap: 5px; `; label.innerHTML = ` Proctoring Active `; videoContainer.appendChild(this.videoElement); videoContainer.appendChild(label); document.body.appendChild(videoContainer); } startFaceDetection() { // Use BlazeFace for accurate face detection this.faceDetectionInterval = setInterval(() => { if (!this.isActive) return; this.detectFace(); }, 1000); // Check every second } async detectFace() { try { if (!this.faceDetectionModel || !this.videoElement) return; // Detect faces using BlazeFace const predictions = await this.faceDetectionModel.estimateFaces(this.videoElement, false); const faceCount = predictions.length; // Update face count indicator this.updateFaceCountIndicator(faceCount); if (faceCount === 0) { // No face detected this.noFaceDetectedTime += 1000; if (this.noFaceDetectedTime >= this.lookAwayThreshold) { this.handleNoFaceDetected(); this.noFaceDetectedTime = 0; } } else if (faceCount === 1) { // Exactly one face - good! this.noFaceDetectedTime = 0; this.lastFaceDetected = Date.now(); } else if (faceCount > 1) { // Multiple faces detected this.handleMultipleFaces(faceCount); } } catch (error) { console.error('Face detection error:', error); } } handleNoFaceDetected() { this.lookAwayCount++; this.logProctoringEvent('no_face_detected', { count: this.lookAwayCount, timestamp: new Date().toISOString() }); if (this.lookAwayCount >= this.maxLookAwayWarnings) { this.terminateExam(); } else { const remaining = this.maxLookAwayWarnings - this.lookAwayCount; this.showWarning( `⚠️ NO FACE DETECTED!\n\nYou have ${remaining} warning(s) remaining.\n\nPlease look at the camera and stay in frame.` ); } } handleMultipleFaces(count) { this.multipleFacesCount++; this.logProctoringEvent('multiple_faces_detected', { face_count: count, violation_count: this.multipleFacesCount, timestamp: new Date().toISOString() }); if (this.multipleFacesCount >= 2) { this.terminateExamMultipleFaces(count); } else { this.showWarning( `⚠️ MULTIPLE PEOPLE DETECTED!\n\n${count} faces detected in frame.\n\nOnly the candidate should be visible.\n\nThis is your only warning.` ); } } terminateExamMultipleFaces(count) { this.isActive = false; this.logProctoringEvent('exam_terminated', { reason: 'multiple_faces_detected', face_count: count, timestamp: new Date().toISOString() }); const overlay = document.createElement('div'); overlay.className = 'critical-warning-overlay show'; overlay.style.zIndex = '99999'; overlay.innerHTML = `

Interview Terminated

Multiple people detected in the camera frame.

Only the candidate should be visible during the interview.

Your interview has been automatically submitted with current progress.

`; document.body.appendChild(overlay); this.stopProctoring(); } updateFaceCountIndicator(count) { const videoContainer = document.getElementById('proctoringVideo'); if (!videoContainer) return; let indicator = document.getElementById('faceCountIndicator'); if (!indicator) { indicator = document.createElement('div'); indicator.id = 'faceCountIndicator'; indicator.style.cssText = ` position: absolute; top: 5px; right: 5px; background: rgba(0, 0, 0, 0.7); color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; `; videoContainer.appendChild(indicator); } if (count === 0) { indicator.style.background = 'rgba(239, 68, 68, 0.9)'; indicator.innerHTML = ' No Face'; } else if (count === 1) { indicator.style.background = 'rgba(16, 185, 129, 0.9)'; indicator.innerHTML = ' 1 Face'; } else { indicator.style.background = 'rgba(239, 68, 68, 0.9)'; indicator.innerHTML = ` ${count} Faces`; } } handleLookingAway() { const timeSinceLastFace = Date.now() - this.lastFaceDetected; if (timeSinceLastFace > this.lookAwayThreshold && !this.lookAwayTimer) { this.lookAwayTimer = setTimeout(() => { this.lookAwayCount++; this.logProctoringEvent('looking_away', { count: this.lookAwayCount, timestamp: new Date().toISOString() }); if (this.lookAwayCount >= this.maxLookAwayWarnings) { this.terminateExam(); } else { const remaining = this.maxLookAwayWarnings - this.lookAwayCount; this.showWarning( `⚠️ FACE NOT DETECTED!\n\nYou have ${remaining} warning(s) remaining.\n\nPlease look at the camera and stay focused on the screen.` ); } this.lookAwayTimer = null; }, 1000); } } clearLookAwayTimer() { if (this.lookAwayTimer) { clearTimeout(this.lookAwayTimer); this.lookAwayTimer = null; } } terminateExam() { this.isActive = false; this.logProctoringEvent('exam_terminated', { reason: 'looking_away_exceeded', timestamp: new Date().toISOString() }); const overlay = document.createElement('div'); overlay.className = 'critical-warning-overlay show'; overlay.style.zIndex = '99999'; overlay.innerHTML = `

Interview Terminated

You have been looking away from the screen for too long.

Your interview has been automatically submitted with current progress.

`; document.body.appendChild(overlay); this.stopProctoring(); } startAudioMonitoring() { try { // Create audio context this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 256; // Connect microphone to analyser const source = this.audioContext.createMediaStreamSource(this.stream); source.connect(this.analyser); // Start monitoring this.audioCheckInterval = setInterval(() => { this.checkAudioLevel(); }, 500); // Check every 500ms // Add audio indicator to video preview this.addAudioIndicator(); } catch (error) { console.error('Audio monitoring error:', error); } } checkAudioLevel() { if (!this.isActive || !this.analyser) return; const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(dataArray); // Calculate average volume let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const average = sum / dataArray.length; const volumePercent = (average / 255) * 100; // Update audio indicator this.updateAudioIndicator(volumePercent); // Check if volume exceeds threshold (someone speaking) if (volumePercent > this.audioThreshold) { this.suspiciousAudioDuration += 500; // Add 500ms // If speaking for more than 2 seconds (more sensitive) if (this.suspiciousAudioDuration >= 2000) { this.handleAudioViolation(); this.suspiciousAudioDuration = 0; } } else { // Reset if quiet if (this.suspiciousAudioDuration > 0) { this.suspiciousAudioDuration = Math.max(0, this.suspiciousAudioDuration - 500); } } } handleAudioViolation() { this.audioViolationCount++; this.logProctoringEvent('audio_violation', { count: this.audioViolationCount, timestamp: new Date().toISOString() }); if (this.audioViolationCount >= this.maxAudioViolations) { this.terminateExamAudio(); } else { const remaining = this.maxAudioViolations - this.audioViolationCount; this.showWarning( `⚠️ SUSPICIOUS AUDIO DETECTED!\n\nVoice or conversation detected in the background.\n\nYou have ${remaining} warning(s) remaining.\n\nPlease ensure you are alone and silent during the interview.` ); } } terminateExamAudio() { this.isActive = false; this.logProctoringEvent('exam_terminated', { reason: 'audio_violations_exceeded', timestamp: new Date().toISOString() }); const overlay = document.createElement('div'); overlay.className = 'critical-warning-overlay show'; overlay.style.zIndex = '99999'; overlay.innerHTML = `

Interview Terminated

Multiple audio violations detected.

Voice or conversation was detected in the background, which is not allowed during the interview.

Your interview has been automatically submitted with current progress.

`; document.body.appendChild(overlay); this.stopProctoring(); } addAudioIndicator() { const videoContainer = document.getElementById('proctoringVideo'); if (!videoContainer) return; const audioIndicator = document.createElement('div'); audioIndicator.id = 'audioIndicator'; audioIndicator.style.cssText = ` position: absolute; bottom: 5px; left: 5px; right: 5px; height: 20px; background: rgba(0, 0, 0, 0.7); border-radius: 4px; overflow: hidden; `; const audioBar = document.createElement('div'); audioBar.id = 'audioBar'; audioBar.style.cssText = ` height: 100%; width: 0%; background: linear-gradient(90deg, var(--color-success), var(--color-warning), var(--color-danger)); transition: width 0.1s ease; `; const audioLabel = document.createElement('div'); audioLabel.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 10px; font-weight: bold; pointer-events: none; `; audioLabel.innerHTML = ' Audio'; audioIndicator.appendChild(audioBar); audioIndicator.appendChild(audioLabel); videoContainer.appendChild(audioIndicator); } updateAudioIndicator(volumePercent) { const audioBar = document.getElementById('audioBar'); if (audioBar) { audioBar.style.width = `${Math.min(volumePercent, 100)}%`; // Change color based on volume if (volumePercent > this.audioThreshold) { audioBar.style.background = 'var(--color-danger)'; } else { audioBar.style.background = 'linear-gradient(90deg, var(--color-success), var(--color-warning), var(--color-danger))'; } } } stopProctoring() { this.isActive = false; if (this.faceDetectionInterval) { clearInterval(this.faceDetectionInterval); } if (this.audioCheckInterval) { clearInterval(this.audioCheckInterval); } if (this.audioContext) { this.audioContext.close(); } if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); } const videoContainer = document.getElementById('proctoringVideo'); if (videoContainer) { videoContainer.remove(); } } showWarning(message) { const warning = document.createElement('div'); warning.className = 'critical-warning-overlay show'; warning.style.zIndex = '99999'; warning.innerHTML = `

Proctoring Warning

${message.replace(/\n/g, '
')}

`; document.body.appendChild(warning); } showProctoringError(error) { const errorMessage = error.name === 'NotAllowedError' ? 'Camera and microphone access is required for this interview. Please allow access and refresh the page.' : 'Failed to access camera/microphone. Please check your device settings.'; const overlay = document.createElement('div'); overlay.className = 'critical-warning-overlay show'; overlay.style.zIndex = '99999'; overlay.innerHTML = `

Camera Access Required

${errorMessage}

`; document.body.appendChild(overlay); } logProctoringEvent(eventType, data) { console.log(`PROCTORING EVENT: ${eventType}`, data); // Send to backend fetch(getApiUrl('log_security'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Session-Id': getSessionId() }, body: JSON.stringify({ event_type: eventType, data: data, timestamp: new Date().toISOString() }) }).catch(err => console.error('Failed to log proctoring event:', err)); } } // Initialize proctoring system const proctoringSystem = new ProctoringSystem(); // Export for global use window.proctoringSystem = proctoringSystem;