/** * Authoritative live-voice store. * * Single source of truth for who is present / speaking in voice. The backend * WebSocket server is the one relay every frontend client connects to, so it * is the correct place to aggregate the gateway's `voice_active_user` deltas * into a shared snapshot. A late-joining browser must be able to see the same * state as everyone else — this store makes that possible (seeded into the WS * initial states and served via GET /api/voice/status). */ export interface LiveSpeaker { userId: string; username: string; avatar?: string | null; speaking: boolean; /** Epoch ms of the most recent activity (start OR end of speech). */ lastActiveAt: number; } const speakers = new Map(); const MAX_SPEAKERS = 200; /** * Speakers inactive for longer than this are auto-expired from the snapshot. * This handles the case where the gateway disconnects abruptly and never * sends `speaking: false` for active users. */ const SPEAKER_TTL_MS = 30_000; /** Purge speakers that haven't been active recently. */ function purgeStale(): void { const cutoff = Date.now() - SPEAKER_TTL_MS; for (const [id, s] of speakers) { if (!s.speaking && s.lastActiveAt < cutoff) { speakers.delete(id); } } } /** * Record a voice_active_user event. `speaking: true` upserts the speaker as * active; `speaking: false` marks them inactive while keeping them briefly * for the activity timeline (until TTL expiry). */ export function recordSpeaker(data: { userId: string; username?: string; avatar?: string | null; speaking: boolean; }): void { const { userId, speaking } = data; const existing = speakers.get(userId); const speaker: LiveSpeaker = { userId, username: data.username ?? existing?.username ?? "Unknown", avatar: data.avatar ?? existing?.avatar ?? null, speaking, lastActiveAt: Date.now(), }; if (speakers.size >= MAX_SPEAKERS && !existing) { let oldestId: string | null = null; let oldestTs = Infinity; for (const [id, s] of speakers) { if (!s.speaking && s.lastActiveAt < oldestTs) { oldestTs = s.lastActiveAt; oldestId = id; } } if (oldestId) speakers.delete(oldestId); else return; } speakers.set(userId, speaker); } /** * All recently-active speakers, most recently active first. * Stale (non-speaking + old) entries are auto-purged. */ export function getActiveSpeakers(): LiveSpeaker[] { purgeStale(); return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt); } /** Only speakers currently flagged as speaking. */ export function getSpeakingSpeakers(): LiveSpeaker[] { purgeStale(); return [...speakers.values()] .filter((s) => s.speaking) .sort((a, b) => b.lastActiveAt - a.lastActiveAt); } /** * Mark ALL tracked speakers as not-speaking and purge stale ones. * Called when the gateway disconnects from voice — ensures the authoritative * snapshot doesn't carry ghost speakers. */ export function clearAllSpeakers(): void { for (const [, s] of speakers) { s.speaking = false; } purgeStale(); } /** Drop all tracked speakers (used on backend restart). */ export function resetLiveSpeakers(): void { speakers.clear(); }