diff --git a/resources/js/audio-monitor.js b/resources/js/audio-monitor.js new file mode 100644 index 0000000..44c24cf --- /dev/null +++ b/resources/js/audio-monitor.js @@ -0,0 +1,105 @@ +/** + * Real-Time Voice Activity Detector (VAD) & Active Speaking Border Engine + */ + +// Easily configurable Tailwind CSS classes applied when a participant is actively speaking +export const SPEAKING_ACTIVE_CLASSES = [ + 'border-emerald-500/80', +]; + +export class AudioActivityMonitor { + constructor(speakingClasses = SPEAKING_ACTIVE_CLASSES) { + this.audioCtx = null; + this.monitors = new Map(); // key -> { source, analyser, animId, targetElemId } + this.speakingClasses = speakingClasses; + } + + getAudioContext() { + if (!this.audioCtx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (AudioCtx) { + this.audioCtx = new AudioCtx(); + } + } + if (this.audioCtx && this.audioCtx.state === 'suspended') { + this.audioCtx.resume().catch(() => {}); + } + return this.audioCtx; + } + + attach(key, mediaStream, targetElemId, threshold = 12) { + this.detach(key); + if (!mediaStream || !mediaStream.getAudioTracks || mediaStream.getAudioTracks().length === 0) return; + + const ctx = this.getAudioContext(); + if (!ctx) return; + + try { + const source = ctx.createMediaStreamSource(mediaStream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.3; + source.connect(analyser); + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + let isSpeaking = false; + let silenceTimer = null; + + const checkAudio = () => { + const entry = this.monitors.get(key); + if (!entry) return; + + analyser.getByteFrequencyData(dataArray); + let sum = 0; + for (let i = 0; i < bufferLength; i++) { + sum += dataArray[i]; + } + const average = sum / bufferLength; + + const elem = document.getElementById(targetElemId); + if (average > threshold) { + if (!isSpeaking) { + isSpeaking = true; + if (silenceTimer) clearTimeout(silenceTimer); + if (elem) { + elem.classList.add(...this.speakingClasses); + } + } else { + if (silenceTimer) clearTimeout(silenceTimer); + } + silenceTimer = setTimeout(() => { + isSpeaking = false; + if (elem) { + elem.classList.remove(...this.speakingClasses); + } + }, 350); + } + + entry.animId = requestAnimationFrame(checkAudio); + }; + + const animId = requestAnimationFrame(checkAudio); + this.monitors.set(key, { source, analyser, animId, targetElemId }); + } catch (e) { + console.log('VAD attach notice:', e); + } + } + + detach(key) { + if (this.monitors.has(key)) { + const m = this.monitors.get(key); + if (m.animId) cancelAnimationFrame(m.animId); + try { if (m.source) m.source.disconnect(); } catch(e) {} + try { if (m.analyser) m.analyser.disconnect(); } catch(e) {} + const elem = document.getElementById(m.targetElemId); + if (elem) { + elem.classList.remove(...this.speakingClasses); + } + this.monitors.delete(key); + } + } +} + +export const globalAudioMonitor = new AudioActivityMonitor(); diff --git a/resources/js/candidate-room.js b/resources/js/candidate-room.js index 94618ee..8ab05ae 100644 --- a/resources/js/candidate-room.js +++ b/resources/js/candidate-room.js @@ -5,6 +5,7 @@ */ import { safePlayMediaStream, getInitials } from './interview-call.js'; +import { globalAudioMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; import { initMeteredIceServers } from './metered.js'; let editor = null; @@ -504,6 +505,7 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = } export function removeCandidateInterviewerTile(peerId) { + globalAudioMonitor.detach('cand-iv-video-' + peerId); const stream = candidateInterviewerTiles.get(peerId); if (stream && stream.active && latestCallStatus !== 'ended') { return; diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index b8bdd93..71d6ec5 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -5,6 +5,7 @@ */ import { initMeteredIceServers } from './metered.js'; +import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; // --- Global Audio Ringtone Synthesizer Engine --- export class CallRingtoneEngine { @@ -1464,7 +1465,7 @@ export function blockCandidateAction() { } } -export { initMeteredIceServers }; +export { initMeteredIceServers, globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES }; export function safePlayMediaStream(videoElem, stream, isSelf = false) { if (!videoElem || !stream) return; @@ -1475,6 +1476,33 @@ export function safePlayMediaStream(videoElem, stream, isSelf = false) { if (videoElem.srcObject !== stream) { videoElem.srcObject = stream; } + + // Attach dynamic emerald speaking border VAD monitor + if (!isScreenShare && stream.getAudioTracks && stream.getAudioTracks().length > 0) { + let targetId = null; + let monitorKey = videoElem.id; + + if (videoElem.id === 'interviewer-cand-video') { + targetId = 'cand-video-wrapper'; + } else if (videoElem.id === 'interviewer-self-video') { + targetId = 'self-video-wrapper'; + } else if (videoElem.id === 'webcam') { + targetId = 'cand-self-video-box'; + } else if (videoElem.id === 'interviewer-video-default') { + targetId = 'interviewer-placeholder-box'; + } else if (videoElem.id.startsWith('panelist-video-')) { + const pid = videoElem.id.replace('panelist-video-', ''); + targetId = 'panelist-tile-' + pid; + } else if (videoElem.id.startsWith('cand-iv-video-')) { + const pid = videoElem.id.replace('cand-iv-video-', ''); + targetId = 'cand-iv-tile-' + pid; + } + + if (targetId) { + globalAudioMonitor.attach(monitorKey, stream, targetId); + } + } + const playPromise = videoElem.play(); if (playPromise !== undefined) { playPromise.catch(err => { @@ -1512,7 +1540,7 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn if (!tile) { tile = document.createElement('div'); tile.id = 'panelist-tile-' + peerId; - tile.className = 'video-tile relative w-[220px] aspect-video shrink-0 bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all'; + tile.className = 'video-tile relative w-[220px] aspect-video shrink-0 bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all duration-200'; tile.innerHTML = ` @@ -1567,6 +1595,7 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn } export function removePanelistTile(peerId) { + globalAudioMonitor.detach('panelist-video-' + peerId); const tile = document.getElementById('panelist-tile-' + peerId); if (tile) tile.remove(); panelistMeshTiles.delete(peerId); diff --git a/resources/views/components/interview/candidate/proctor-sidebar.blade.php b/resources/views/components/interview/candidate/proctor-sidebar.blade.php index 919e8be..4401057 100644 --- a/resources/views/components/interview/candidate/proctor-sidebar.blade.php +++ b/resources/views/components/interview/candidate/proctor-sidebar.blade.php @@ -4,7 +4,7 @@