sls/resources/js/audio-monitor.js
kushal.saha 03a36c00e6 feat(UI): add speech indicator
- add a audio monitor
- when any peer speaks, and green outline is shown around the camera card
2026-08-18 07:21:39 +00:00

106 lines
3.6 KiB
JavaScript

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