/** * 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();