feat(voice): implement stale speaker management and clear functionality
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user