Files
backspace/packages/web/src/audio/SpeakingDetector.ts
T
Jannis Braun fc72e424d6 refactor: rename "Server Mute/Deafen" to "Space Mute/Deafen" across entire stack
Aligns voice moderation terminology with Backspace's "Spaces" branding.
Renames WS protocol strings, backend handlers, frontend store/hooks/utils,
user-facing labels, and documentation — 15 files, zero functional changes.
2026-03-12 00:12:33 +01:00

271 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useVoiceStore } from '../stores/voiceStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
import { AudioManager } from './AudioManager';
import type { ParticipantInfo } from '../hooks/useLiveKit';
/**
* Client-side speaking detection using Web Audio AnalyserNodes.
*
* Replaces LiveKit's server-side VAD (which has a conservative, non-configurable
* threshold that misses conversational speech) with local RMS analysis per
* participant. Each remote participant gets an AnalyserNode attached to their
* MediaStreamTrack; the local participant reuses AudioManager's existing analyser.
*
* Hysteresis (hold counters) prevents flickering during natural speech pauses.
*/
export class SpeakingDetector {
private static instance: SpeakingDetector;
/** Per-participant analysis nodes (keyed by participant identity) */
private tracks: Map<string, {
source: MediaStreamAudioSourceNode;
analyser: AnalyserNode;
}> = new Map();
/** Identity of the local participant (reads from AudioManager's analyser instead) */
private localIdentity: string | null = null;
/** Polling interval handle */
private intervalId: number | null = null;
/** Reusable buffer for time-domain data (avoids allocations in poll loop) */
private analyserBuffer: Uint8Array<ArrayBuffer> | null = null;
// --- Tuning constants ---
private readonly POLL_MS = 50; // 20 checks/sec
private readonly THRESHOLD = 0.008; // RMS threshold — conversational speech ~0.010.05
private readonly HOLD_FRAMES = 5; // Hold speaking state for 5 polls (250ms) after drop
/** Per-participant hold counters for hysteresis */
private holdCounters: Map<string, number> = new Map();
private constructor() {}
static getInstance(): SpeakingDetector {
if (!SpeakingDetector.instance) {
SpeakingDetector.instance = new SpeakingDetector();
}
return SpeakingDetector.instance;
}
/**
* Sync tracked participants with the current room state.
* Called from updateParticipants() in useLiveKit after setParticipants().
*
* - Creates AnalyserNodes for new remote participants
* - Removes nodes for participants that left
* - Registers localIdentity for AudioManager analyser reads
* - Starts polling if not already running
*/
syncTracks(participants: ParticipantInfo[]): void {
const currentIds = new Set(participants.map(p => p.identity));
// Identify local participant
const localP = participants.find(p => p.isLocal);
this.localIdentity = localP?.identity ?? null;
// Remove stale entries (participants that left)
for (const [identity, entry] of this.tracks) {
if (!currentIds.has(identity)) {
entry.source.disconnect();
entry.analyser.disconnect();
this.tracks.delete(identity);
this.holdCounters.delete(identity);
}
}
// Add/update remote participants
for (const p of participants) {
if (p.isLocal) continue; // Local uses AudioManager's analyser
if (!p.audioTrack || p.audioTrack.readyState !== 'live') {
// No live audio track — remove if we were tracking
const existing = this.tracks.get(p.identity);
if (existing) {
existing.source.disconnect();
existing.analyser.disconnect();
this.tracks.delete(p.identity);
this.holdCounters.delete(p.identity);
}
continue;
}
// Check if we already have an analyser for this exact track
const existing = this.tracks.get(p.identity);
if (existing) {
// Verify the source is still connected to the same track
// MediaStreamAudioSourceNode doesn't expose its track, so we check
// if the existing entry's source's mediaStream still has live tracks
const srcStream = existing.source.mediaStream;
const srcTrack = srcStream?.getAudioTracks()[0];
if (srcTrack && srcTrack.id === p.audioTrack.id && srcTrack.readyState === 'live') {
continue; // Already tracking this exact track
}
// Track changed — disconnect old and create new
existing.source.disconnect();
existing.analyser.disconnect();
this.tracks.delete(p.identity);
}
// Create analysis-only nodes for this remote participant.
// This does NOT interfere with useAudioTrackPlayer's playback pipeline —
// we create a separate MediaStreamAudioSourceNode from the same track
// (supported by Web Audio spec) and route it to an unconnected AnalyserNode.
try {
const ctx = AudioManager.getInstance().ensureContext();
const stream = new MediaStream([p.audioTrack]);
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
// Connect source → analyser (analyser is NOT connected to destination,
// so no audio output, purely for level reading)
source.connect(analyser);
this.tracks.set(p.identity, { source, analyser });
} catch (err) {
console.error(`[SpeakingDetector] Failed to create analyser for ${p.identity}:`, err);
}
}
// Start polling if we have participants and aren't already polling
if (participants.length > 0 && this.intervalId === null) {
this.intervalId = window.setInterval(() => this.poll(), this.POLL_MS);
}
// Stop polling if no participants
if (participants.length === 0 && this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* Disconnect all nodes, stop polling, clear state.
* Called on room disconnect / cleanup.
*/
clear(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
for (const [, entry] of this.tracks) {
entry.source.disconnect();
entry.analyser.disconnect();
}
this.tracks.clear();
this.holdCounters.clear();
this.localIdentity = null;
this.analyserBuffer = null;
}
/**
* Core polling loop — reads RMS from each participant's AnalyserNode,
* applies hysteresis, and writes the speaking set to voiceStore if changed.
*/
private poll(): void {
const speakingIds = new Set<string>();
const store = useVoiceStore.getState();
// --- Local participant ---
if (this.localIdentity) {
// Compute effective muted/deafened state (intent OR server enforcement)
let effectiveMuted = store.isMuted;
let effectiveDeafened = store.isDeafened;
const channelId = store.currentVoiceChannelId;
if (channelId) {
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(channelId);
if (spaceId) {
const myOriginId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myOriginId) {
const spaceKey = `${spaceId}:${myOriginId}`;
effectiveMuted = effectiveMuted || store.spaceMutedUserIds.has(spaceKey);
effectiveDeafened = effectiveDeafened || store.spaceDeafenedUserIds.has(spaceKey);
}
}
}
if (effectiveMuted || effectiveDeafened) {
// Muted or deafened → never speaking, reset hold counter
this.holdCounters.delete(this.localIdentity);
} else {
const analyser = AudioManager.getInstance().getAnalyserNode();
const rms = this.computeRMS(analyser);
if (this.applyHysteresis(this.localIdentity, rms)) {
speakingIds.add(this.localIdentity);
}
}
}
// --- Remote participants ---
for (const [identity, entry] of this.tracks) {
const rms = this.computeRMS(entry.analyser);
if (this.applyHysteresis(identity, rms)) {
speakingIds.add(identity);
}
}
// Only write to store if the set actually changed
const current = store.speakingParticipantIds;
if (!this.setsEqual(current, speakingIds)) {
store.setSpeakingParticipants(speakingIds);
}
}
/**
* Apply hysteresis to a single participant's RMS reading.
* Returns true if the participant should be considered "speaking".
*/
private applyHysteresis(identity: string, rms: number): boolean {
if (rms > this.THRESHOLD) {
// Above threshold — mark speaking, reset hold counter
this.holdCounters.set(identity, this.HOLD_FRAMES);
return true;
}
// Below threshold — decrement hold counter
const hold = this.holdCounters.get(identity) ?? 0;
if (hold > 0) {
this.holdCounters.set(identity, hold - 1);
return true; // Still in hold period
}
return false; // Truly silent
}
/**
* Compute RMS (root mean square) of an AnalyserNode's time-domain data.
* Returns a value 0.01.0 representing the audio level.
*
* getByteTimeDomainData returns unsigned bytes 0255, where 128 = silence.
* RMS = sqrt(mean((sample - 128)^2)) / 128
*/
private computeRMS(analyser: AnalyserNode): number {
const bufferLength = analyser.fftSize;
// Reuse or allocate buffer
if (!this.analyserBuffer || this.analyserBuffer.length !== bufferLength) {
this.analyserBuffer = new Uint8Array(bufferLength);
}
analyser.getByteTimeDomainData(this.analyserBuffer);
let sumSquares = 0;
for (let i = 0; i < bufferLength; i++) {
const normalized = (this.analyserBuffer[i]! - 128) / 128;
sumSquares += normalized * normalized;
}
return Math.sqrt(sumSquares / bufferLength);
}
/** Compare two sets for equality */
private setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) {
if (!b.has(v)) return false;
}
return true;
}
}