- add a audio monitor - when any peer speaks, and green outline is shown around the camera card
106 lines
3.6 KiB
JavaScript
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();
|