From 4f4f92706cc330921cad619a33acd33f9ffd1c8d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 26 Aug 2026 23:16:57 +0700 Subject: [PATCH] feat(voice): implement stale speaker management and clear functionality --- .../backend/src/modules/voice/live-speaker.ts | 44 +++++++-- services/backend/src/ws/redis-bridge.ts | 11 ++- .../recorder/segmentFinalizer.ts | 15 +++ .../src/app/(dashboard)/voice/view.tsx | 91 +++++++++++++++++-- .../src/components/voice/voice-stage.tsx | 12 ++- services/frontend/src/hooks/use-voice.ts | 31 ++++++- services/frontend/src/lib/types/voice.ts | 2 + 7 files changed, 178 insertions(+), 28 deletions(-) diff --git a/services/backend/src/modules/voice/live-speaker.ts b/services/backend/src/modules/voice/live-speaker.ts index 43e5461e..bac290b9 100644 --- a/services/backend/src/modules/voice/live-speaker.ts +++ b/services/backend/src/modules/voice/live-speaker.ts @@ -23,14 +23,26 @@ const speakers = new Map(); const MAX_SPEAKERS = 200; /** - * Record a voice_active_user event. `speaking: true` upserts the speaker as - * active; `speaking: false` marks them inactive while keeping them for the - * activity timeline. + * 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); + } + } +} + /** - * recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true` - * upserts the speaker as ACTIVE; `speaking: false` marks them inactive while - * keeping them for the activity timeline. + * 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; @@ -49,7 +61,6 @@ export function recordSpeaker(data: { }; if (speakers.size >= MAX_SPEAKERS && !existing) { - // Drop the least-recently-active non-speaking speaker to stay bounded. let oldestId: string | null = null; let oldestTs = Infinity; for (const [id, s] of speakers) { @@ -65,18 +76,35 @@ export function recordSpeaker(data: { 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[] { + 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 [id, s] of speakers) { + s.speaking = false; + } + purgeStale(); +} + /** Drop all tracked speakers (used on backend restart). */ export function resetLiveSpeakers(): void { speakers.clear(); diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index 0bd39cdb..ce9224da 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -1,10 +1,11 @@ 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 { DISCORD_CHANNEL_TO_WS_EVENT, DISCORD_VOICE_ACTIVE_USER, DISCORD_VOICE_PCM, + DISCORD_VOICE_STOPPED, } from "../shared/index.js"; import { createChildLogger } from "../shared/logger/index.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"); broadcastEvent(eventType, data); } diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts b/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts index 427e3ba4..2579ecdb 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts @@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void { } = input; 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) { logger.info({ filename: currentSegment.filename }, "Segment saved"); diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index e5a8bb97..3f64998e 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -53,6 +53,32 @@ export function VoiceView({ ); const [micVol, setMicVol] = useState(100); 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(".voice-tile", { stagger: 0.04, @@ -202,12 +228,21 @@ export function VoiceView({
+ {/* Mic Toggle */}
-
- - - Mic Sensitivity - +
+ {micVol}% @@ -224,14 +259,32 @@ export function VoiceView({ }} className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal" /> + {/* Live mic level meter */} + {micActive && ( +
+
+
+ )}
+ {/* Listen Toggle */}
-
- - - Monitor Output - +
+ {listenVol}% @@ -248,6 +301,24 @@ export function VoiceView({ }} 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 && ( +
+ {Array.from(listen.levels.entries()).map(([hash, level]) => ( +
+ + #{hash.toString(16).slice(-3)} + +
+
+
+
+ ))} +
+ )}
diff --git a/services/frontend/src/components/voice/voice-stage.tsx b/services/frontend/src/components/voice/voice-stage.tsx index f0ae99bb..d68333ff 100644 --- a/services/frontend/src/components/voice/voice-stage.tsx +++ b/services/frontend/src/components/voice/voice-stage.tsx @@ -7,9 +7,11 @@ import type { ActiveSpeaker } from "@/lib/types"; export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) { const containerRef = useRef(null); - const n = speakers.length; - const speaking = speakers.filter((s) => s.speaking).length; - const live = speaking > 0; + // Only show actively speaking users on the stage orbit + const activeSpeakers = speakers.filter((s) => s.speaking); + const n = activeSpeakers.length; + const totalConnected = speakers.length; + const live = n > 0; // CSS stagger reveal for speaker nodes useEffect(() => { @@ -92,11 +94,11 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) { className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`} /> - {live ? `${speaking} SPEAKING` : `${n} CONNECTED`} + {live ? `${n} SPEAKING` : `${totalConnected} CONNECTED`}
- {speakers.map((s, i) => { + {activeSpeakers.map((s, i) => { const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2; const radius = 44; const x = 50 + radius * Math.cos(angle); diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts index 342543fe..f918a13b 100644 --- a/services/frontend/src/hooks/use-voice.ts +++ b/services/frontend/src/hooks/use-voice.ts @@ -47,6 +47,18 @@ const SPEAKERS_KEY = ["voice-speakers"] as const; * This replaces the old per-browser model where each tab accumulated speakers * 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[]) { const { data: speakers, @@ -65,7 +77,7 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { const unsubSnapshot = ws.on("voice_state", (data) => { const state = data as { activeSpeakers?: ActiveSpeaker[] }; 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) => { @@ -74,12 +86,13 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { (prev: ActiveSpeaker[] | undefined) => { const arr = prev ?? []; const idx = arr.findIndex((s) => s.userId === speaker.userId); + const next = [...arr]; if (idx >= 0) { - const next = [...arr]; next[idx] = speaker; - return next; + } else { + next.push(speaker); } - return [...arr, speaker]; + return filterStale(next); }, { revalidate: false }, ); @@ -92,6 +105,16 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { [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 }; } diff --git a/services/frontend/src/lib/types/voice.ts b/services/frontend/src/lib/types/voice.ts index 44dd2d1f..b0304aae 100644 --- a/services/frontend/src/lib/types/voice.ts +++ b/services/frontend/src/lib/types/voice.ts @@ -24,4 +24,6 @@ export interface ActiveSpeaker { username: string; avatar?: string | null; speaking: boolean; + /** Epoch ms of most recent activity. Stale speakers are auto-expired. */ + lastActiveAt?: number; }