(feat): add speaking indicator
All checks were successful
/ build-web (push) Successful in 1m19s
/ build-desktop (push) Successful in 13m14s
/ build-mobile (push) Successful in 18m45s
/ release (push) Successful in 25s

(feat): add microphone gate
(qol): update todo
This commit is contained in:
Alois 2026-05-15 15:43:53 +02:00
commit e76c579bb4
5 changed files with 363 additions and 13 deletions

View file

@ -0,0 +1,249 @@
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<number, AnalyserEntry>();
private intervalId: ReturnType<typeof setInterval> | 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<number, boolean>();
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));
}