import { useCall } from "./store"; const SPEAKING_THRESHOLD = 0.01; const SPEAKING_HANGTIME_MS = 250; const ANALYSIS_INTERVAL_MS = 30; const FFT_SIZE = 256; type AnalyserEntry = { source: MediaStreamAudioSourceNode; analyser: AnalyserNode; track: MediaStreamTrack; originalTrack?: MediaStreamTrack; lastSpeakingTime: number; isSpeaking: boolean; }; class SpeakingDetector { private audioContext: AudioContext | null = null; private entries = new Map(); private intervalId: ReturnType | null = null; private deaf = false; private gateThresholdStart = -50; private gateThresholdEnd = -40; private localParticipantId: number | null = null; private localMicGateClosed = false; private ensureAudioContext(): AudioContext { if (!this.audioContext) { this.audioContext = new AudioContext(); } if (this.audioContext.state === "suspended") { void this.audioContext.resume(); } return this.audioContext; } setLocalParticipantId(id: number) { this.localParticipantId = id; } setGateThresholds(start: number, end: number) { this.gateThresholdStart = start; this.gateThresholdEnd = end; } addTrack( participantId: number, track: MediaStreamTrack, originalTrack?: MediaStreamTrack, ) { if (track.kind !== "audio") return; this.removeParticipant(participantId); const ctx = this.ensureAudioContext(); const stream = new MediaStream([track]); const source = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = FFT_SIZE; source.connect(analyser); this.entries.set(participantId, { source, analyser, track, originalTrack, lastSpeakingTime: 0, isSpeaking: false, }); if (!this.intervalId) { this.startLoop(); } } removeParticipant(participantId: number) { const entry = this.entries.get(participantId); if (!entry) return; try { entry.source.disconnect(); } catch { // ignore } this.entries.delete(participantId); useCall.setState((state) => { if (!state.speakingParticipantIds.has(participantId)) return state; const next = new Set(state.speakingParticipantIds); next.delete(participantId); return { speakingParticipantIds: next }; }); if (participantId === this.localParticipantId && this.localMicGateClosed) { this.muteLocalTrack(false); } } setDeaf(deaf: boolean) { this.deaf = deaf; if (deaf) { for (const entry of this.entries.values()) { entry.isSpeaking = false; entry.lastSpeakingTime = 0; } useCall.setState({ speakingParticipantIds: new Set() }); } } private startLoop() { if (this.intervalId) return; this.intervalId = setInterval(() => this.analyse(), ANALYSIS_INTERVAL_MS); } private stopLoop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } } private muteLocalTrack(muted: boolean) { const entry = this.localParticipantId ? this.entries.get(this.localParticipantId) : undefined; const target = entry?.originalTrack ?? entry?.track; if (target && target.enabled === muted) { target.enabled = !muted; } this.localMicGateClosed = muted; useCall.setState({ micGated: muted }); } private applyNoiseGate(rms: number) { const db = 20 * Math.log10(Math.max(rms, 0.0001)); if (!this.localMicGateClosed && db < this.gateThresholdStart) { this.muteLocalTrack(true); } else if (this.localMicGateClosed && db > this.gateThresholdEnd) { this.muteLocalTrack(false); } } private analyse() { if (this.deaf || this.entries.size === 0) return; const now = Date.now(); const changed = new Map(); for (const [participantId, entry] of this.entries) { const { analyser, track } = entry; if (track.muted || track.readyState === "ended" || !track.enabled) { if (entry.isSpeaking) { entry.isSpeaking = false; entry.lastSpeakingTime = 0; changed.set(participantId, false); } continue; } const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyser.getByteTimeDomainData(dataArray); let sum = 0; for (let i = 0; i < bufferLength; i++) { const sample = (dataArray[i] - 128) / 128.0; sum += sample * sample; } const rms = Math.sqrt(sum / bufferLength); let nextIsSpeaking = entry.isSpeaking; if (rms > SPEAKING_THRESHOLD) { entry.lastSpeakingTime = now; nextIsSpeaking = true; } else if (now - entry.lastSpeakingTime > SPEAKING_HANGTIME_MS) { nextIsSpeaking = false; } if (participantId === this.localParticipantId) { this.applyNoiseGate(rms); if (this.localMicGateClosed) { nextIsSpeaking = false; } } if (nextIsSpeaking !== entry.isSpeaking) { entry.isSpeaking = nextIsSpeaking; changed.set(participantId, nextIsSpeaking); } } if (changed.size > 0) { useCall.setState((state) => { let hasDiff = false; const next = new Set(state.speakingParticipantIds); for (const [id, speaking] of changed) { if (speaking) { if (!next.has(id)) { next.add(id); hasDiff = true; } } else { if (next.has(id)) { next.delete(id); hasDiff = true; } } } return hasDiff ? { speakingParticipantIds: next } : state; }); } } dispose() { this.stopLoop(); for (const id of Array.from(this.entries.keys())) { this.removeParticipant(id); } this.entries.clear(); if (this.audioContext) { void this.audioContext.close(); this.audioContext = null; } } } let detectorInstance: SpeakingDetector | null = null; export function getSpeakingDetector(): SpeakingDetector { if (!detectorInstance) { detectorInstance = new SpeakingDetector(); } return detectorInstance; } export function disposeSpeakingDetector(): void { if (detectorInstance) { detectorInstance.dispose(); detectorInstance = null; } } export function useIsSpeaking(participantId: number): boolean { return useCall((state) => state.speakingParticipantIds.has(participantId)); }