feat(voice): implement stale speaker management and clear functionality

This commit is contained in:
asepharyana
2026-08-26 23:16:57 +07:00
parent 611ba39973
commit 4f4f92706c
7 changed files with 178 additions and 28 deletions
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200; const MAX_SPEAKERS = 200;
/** /**
* Record a voice_active_user event. `speaking: true` upserts the speaker as * Speakers inactive for longer than this are auto-expired from the snapshot.
* active; `speaking: false` marks them inactive while keeping them for the * This handles the case where the gateway disconnects abruptly and never
* activity timeline. * 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);
}
}
}
/** /**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true` * Record a voice_active_user event. `speaking: true` upserts the speaker as
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while * active; `speaking: false` marks them inactive while keeping them briefly
* keeping them for the activity timeline. * for the activity timeline (until TTL expiry).
*/ */
export function recordSpeaker(data: { export function recordSpeaker(data: {
userId: string; userId: string;
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
}; };
if (speakers.size >= MAX_SPEAKERS && !existing) { if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null; let oldestId: string | null = null;
let oldestTs = Infinity; let oldestTs = Infinity;
for (const [id, s] of speakers) { for (const [id, s] of speakers) {
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
speakers.set(userId, speaker); speakers.set(userId, speaker);
} }
/** All known speakers, most recently active first. */ /**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] { export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt); return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
} }
/** Only speakers currently flagged as speaking. */ /** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] { export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()] return [...speakers.values()]
.filter((s) => s.speaking) .filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt); .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 [id, s] of speakers) {
s.speaking = false;
}
purgeStale();
}
/** Drop all tracked speakers (used on backend restart). */ /** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void { export function resetLiveSpeakers(): void {
speakers.clear(); speakers.clear();
+10 -1
View File
@@ -1,10 +1,11 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { recordSpeaker } from "../modules/voice/live-speaker.js"; import { clearAllSpeakers, recordSpeaker } from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js"; import { config } from "../shared/config/index.js";
import { import {
DISCORD_CHANNEL_TO_WS_EVENT, DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER, DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM, DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} from "../shared/index.js"; } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js"; import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js"; import { broadcastBinary, broadcastEvent } from "./broadcast.js";
@@ -84,6 +85,14 @@ function handleSubscriptionMessage(channel: string, message: string): void {
} }
} }
// When the gateway stops voice recording (disconnects from voice channel),
// clear all speakers from the authoritative snapshot so frontends don't
// show ghost participants.
if (channel === DISCORD_VOICE_STOPPED) {
clearAllSpeakers();
logger.info("Voice recording stopped — cleared all live speakers");
}
logger.debug({ channel, eventType }, "Broadcasting Redis event"); logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data); broadcastEvent(eventType, data);
} }
@@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
} = input; } = input;
const endTime = currentSegment.endTime ?? Date.now(); const endTime = currentSegment.endTime ?? Date.now();
const durationMs = endTime - currentSegment.startTime;
// Discard segments shorter than 1 second — not useful as a recording,
// would just be a blip of ambient noise or a mic click.
const MIN_DURATION_MS = 1000;
if (durationMs < MIN_DURATION_MS) {
logger.debug(
{ filename: currentSegment.filename, durationMs },
"Segment too short, discarding",
);
// Clean up the OGG file
fsPromises.unlink(currentSegment.filename).catch(() => {});
fsPromises.unlink(currentSegment.jsonFilename).catch(() => {});
return;
}
if (config.VERBOSE) { if (config.VERBOSE) {
logger.info({ filename: currentSegment.filename }, "Segment saved"); logger.info({ filename: currentSegment.filename }, "Segment saved");
@@ -53,6 +53,32 @@ export function VoiceView({
); );
const [micVol, setMicVol] = useState(100); const [micVol, setMicVol] = useState(100);
const [listenVol, setListenVol] = useState(75); const [listenVol, setListenVol] = useState(75);
const [micActive, setMicActive] = useState(false);
const [listenActive, setListenActive] = useState(false);
const toggleMic = async () => {
const next = !micActive;
try {
await mic.mutateAsync(next);
setMicActive(next);
toast({
title: next ? "Mic activated" : "Mic deactivated",
tone: next ? "signal" : "neutral",
});
} catch (e) {
toast({ title: "Mic toggle failed", description: String(e), tone: "vermilion" });
}
};
const toggleListen = () => {
const next = !listenActive;
listen.toggle(next);
setListenActive(next);
toast({
title: next ? "Monitor activated" : "Monitor deactivated",
tone: next ? "signal" : "neutral",
});
};
const containerRef = useStaggerReveal<HTMLDivElement>(".voice-tile", { const containerRef = useStaggerReveal<HTMLDivElement>(".voice-tile", {
stagger: 0.04, stagger: 0.04,
@@ -202,12 +228,21 @@ export function VoiceView({
<div> <div>
<SectionHeader eyebrow="Telemetry" title="Input / Output Mix" /> <SectionHeader eyebrow="Telemetry" title="Input / Output Mix" />
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{/* Mic Toggle */}
<div> <div>
<div className="flex justify-between text-xs font-medium text-ink-soft"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5"> <button
<Mic className="size-3.5 text-signal" /> type="button"
Mic Sensitivity onClick={toggleMic}
</span> className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
micActive
? "bg-signal/15 text-signal border border-signal/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-signal/30 hover:text-ink"
}`}
>
<Mic className="size-3.5" />
{micActive ? "MIC LIVE" : "MIC OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted"> <span className="font-mono text-[11px] text-ink-muted">
{micVol}% {micVol}%
</span> </span>
@@ -224,14 +259,32 @@ export function VoiceView({
}} }}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal" className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal"
/> />
{/* Live mic level meter */}
{micActive && (
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal transition-all duration-100"
style={{ width: `${Math.min(100, mic.micLevel * 100)}%` }}
/>
</div>
)}
</div> </div>
{/* Listen Toggle */}
<div> <div>
<div className="flex justify-between text-xs font-medium text-ink-soft"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5"> <button
<Volume2 className="size-3.5 text-success" /> type="button"
Monitor Output onClick={toggleListen}
</span> className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
listenActive
? "bg-success/15 text-success border border-success/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-success/30 hover:text-ink"
}`}
>
<Volume2 className="size-3.5" />
{listenActive ? "MONITOR LIVE" : "MONITOR OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted"> <span className="font-mono text-[11px] text-ink-muted">
{listenVol}% {listenVol}%
</span> </span>
@@ -248,6 +301,24 @@ export function VoiceView({
}} }}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-success" className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-success"
/> />
{/* Per-speaker level meters */}
{listenActive && listen.levels.size > 0 && (
<div className="mt-2 space-y-1">
{Array.from(listen.levels.entries()).map(([hash, level]) => (
<div key={hash} className="flex items-center gap-2">
<span className="font-mono text-[9px] text-ink-faint w-8">
#{hash.toString(16).slice(-3)}
</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-success transition-all duration-100"
style={{ width: `${Math.min(100, level * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -7,9 +7,11 @@ import type { ActiveSpeaker } from "@/lib/types";
export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) { export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const n = speakers.length; // Only show actively speaking users on the stage orbit
const speaking = speakers.filter((s) => s.speaking).length; const activeSpeakers = speakers.filter((s) => s.speaking);
const live = speaking > 0; const n = activeSpeakers.length;
const totalConnected = speakers.length;
const live = n > 0;
// CSS stagger reveal for speaker nodes // CSS stagger reveal for speaker nodes
useEffect(() => { useEffect(() => {
@@ -92,11 +94,11 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`} className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`}
/> />
<span className="font-mono mt-1 text-[11px] font-bold tracking-wider text-ink uppercase"> <span className="font-mono mt-1 text-[11px] font-bold tracking-wider text-ink uppercase">
{live ? `${speaking} SPEAKING` : `${n} CONNECTED`} {live ? `${n} SPEAKING` : `${totalConnected} CONNECTED`}
</span> </span>
</div> </div>
{speakers.map((s, i) => { {activeSpeakers.map((s, i) => {
const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2; const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
const radius = 44; const radius = 44;
const x = 50 + radius * Math.cos(angle); const x = 50 + radius * Math.cos(angle);
+27 -4
View File
@@ -47,6 +47,18 @@ const SPEAKERS_KEY = ["voice-speakers"] as const;
* This replaces the old per-browser model where each tab accumulated speakers * This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted. * only from events it happened to receive while mounted.
*/ */
const SPEAKER_TTL_MS = 30_000;
/** Remove speakers that haven't been active recently. */
function filterStale(speakers: ActiveSpeaker[]): ActiveSpeaker[] {
const now = Date.now();
return speakers.filter((s) => {
if (s.speaking) return true;
if (s.lastActiveAt && now - s.lastActiveAt > SPEAKER_TTL_MS) return false;
return true;
});
}
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const { const {
data: speakers, data: speakers,
@@ -65,7 +77,7 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const unsubSnapshot = ws.on("voice_state", (data) => { const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] }; const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) { if (Array.isArray(state?.activeSpeakers)) {
void mutate(state.activeSpeakers, { revalidate: false }); void mutate(filterStale(state.activeSpeakers), { revalidate: false });
} }
}); });
const unsub = ws.on("voice_active_user", (data) => { const unsub = ws.on("voice_active_user", (data) => {
@@ -74,12 +86,13 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
(prev: ActiveSpeaker[] | undefined) => { (prev: ActiveSpeaker[] | undefined) => {
const arr = prev ?? []; const arr = prev ?? [];
const idx = arr.findIndex((s) => s.userId === speaker.userId); const idx = arr.findIndex((s) => s.userId === speaker.userId);
const next = [...arr];
if (idx >= 0) { if (idx >= 0) {
const next = [...arr];
next[idx] = speaker; next[idx] = speaker;
return next; } else {
next.push(speaker);
} }
return [...arr, speaker]; return filterStale(next);
}, },
{ revalidate: false }, { revalidate: false },
); );
@@ -92,6 +105,16 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
[mutate], [mutate],
); );
// Periodic stale speaker cleanup (every 10s)
useEffect(() => {
const timer = setInterval(() => {
void mutate((prev) => (prev ? filterStale(prev) : prev), {
revalidate: false,
});
}, 10_000);
return () => clearInterval(timer);
}, [mutate]);
return { speakers: speakers ?? [], subscribe, error, isValidating }; return { speakers: speakers ?? [], subscribe, error, isValidating };
} }
+2
View File
@@ -24,4 +24,6 @@ export interface ActiveSpeaker {
username: string; username: string;
avatar?: string | null; avatar?: string | null;
speaking: boolean; speaking: boolean;
/** Epoch ms of most recent activity. Stale speakers are auto-expired. */
lastActiveAt?: number;
} }