fix(gateway): voice speaking handler, metadata cache, transmitter backpressure

- speakingHandler: check subscriptions.has() BEFORE voiceActiveUser broadcast
- segment.ts: add LRU metadata cache (200 entries, avoids Discord API in hotpath)
- transmitter.ts: add backpressure handling with drain queue/flush
  (PassThrough.write() return value was ignored — potential OOM under load)
- cleanup backpressure queue on transmitter stop

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-13 14:38:06 +07:00
co-authored by Claude
parent 29d4cb87b6
commit 3965ea79bc
3 changed files with 34 additions and 9 deletions
@@ -13,11 +13,15 @@ import type { RecordingSession } from "./sessionRecording.js";
import { uploadRecordingSegment } from "./uploader.js"; import { uploadRecordingSegment } from "./uploader.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Logger // Logger & metadata cache
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const logger = createChildLogger("voice-segment"); const logger = createChildLogger("voice-segment");
/** LRU-ish cache: userId -> UserMetadata. Avoids Discord API calls in hotpath. */
const metadataCache = new Map<string, UserMetadata>();
const METADATA_CACHE_MAX = 200;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// collectUserMetadata (was metadata.ts) // collectUserMetadata (was metadata.ts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -27,7 +31,8 @@ export async function collectUserMetadata(
userId: string, userId: string,
channel: VoiceChannel, channel: VoiceChannel,
): Promise<UserMetadata> { ): Promise<UserMetadata> {
logger.debug({ userId }, "Collecting user metadata"); const cached = metadataCache.get(userId);
if (cached) return cached;
const user = const user =
client.users.cache.get(userId) || client.users.cache.get(userId) ||
@@ -52,7 +57,7 @@ export async function collectUserMetadata(
position: role.position, position: role.position,
})) ?? []; })) ?? [];
return { const result: UserMetadata = {
userId, userId,
username, username,
tag: user?.tag ?? "Unknown#0000", tag: user?.tag ?? "Unknown#0000",
@@ -76,7 +81,9 @@ export async function collectUserMetadata(
highestRole: roles[0] ?? null, highestRole: roles[0] ?? null,
joinedTimestamp: member?.joinedTimestamp ?? null, joinedTimestamp: member?.joinedTimestamp ?? null,
}; };
}
cacheMetadata(userId, result);
return result;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers (was segment.ts) // Path helpers (was segment.ts)
@@ -54,6 +54,10 @@ export function createSpeakingHandler(
"Voice activity detected", "Voice activity detected",
); );
// Skip if user already has an active stream subscription
// (check BEFORE broadcast to avoid false positive events)
if (receiver.subscriptions.has(userId)) return;
// Notify webserver / WebSocket clients // Notify webserver / WebSocket clients
eventBroadcaster?.voiceActiveUser(userId, { eventBroadcaster?.voiceActiveUser(userId, {
username: userMetadata.username, username: userMetadata.username,
@@ -61,9 +65,6 @@ export function createSpeakingHandler(
speaking: true, speaking: true,
}); });
// Skip if user already has an active stream subscription
if (receiver.subscriptions.has(userId)) return;
// Ensure per-user recording directory // Ensure per-user recording directory
const userDir = path.join(recordingsDir, userId); const userDir = path.join(recordingsDir, userId);
await fsPromises.mkdir(userDir, { recursive: true }).catch(() => { await fsPromises.mkdir(userDir, { recursive: true }).catch(() => {
@@ -21,6 +21,9 @@ export class VoiceTransmitter {
private ffmpegProcess: ReturnType<typeof spawn> | null = null; private ffmpegProcess: ReturnType<typeof spawn> | null = null;
private isActive = false; private isActive = false;
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT; private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
/** Queue for PCM chunks when backpressure is active */
private backpressureQueue: Buffer[] = [];
private draining = false;
/** /**
* Start listening for PCM audio data from Redis and stream to Discord * Start listening for PCM audio data from Redis and stream to Discord
@@ -130,8 +133,19 @@ export class VoiceTransmitter {
const data = JSON.parse(message); const data = JSON.parse(message);
if (data.type === "pcm" && data.buffer) { if (data.type === "pcm" && data.buffer) {
const pcmBuffer = Buffer.from(data.buffer, "base64"); const pcmBuffer = Buffer.from(data.buffer, "base64");
logger.debug({ bytes: pcmBuffer.length }, "Received PCM chunk"); const canContinue = this.pcmStream.write(pcmBuffer);
this.pcmStream.write(pcmBuffer); // Backpressure: queue until drain
if (!canContinue) {
this.draining = true;
this.pcmStream.once("drain", () => {
this.draining = false;
// Flush queued chunks
while (this.backpressureQueue.length > 0) {
const queued = this.backpressureQueue.shift()!;
if (!this.pcmStream.write(queued)) break;
}
});
}
} }
} catch (err) { } catch (err) {
logger.error({ error: err }, "Failed to process PCM data"); logger.error({ error: err }, "Failed to process PCM data");
@@ -150,6 +164,9 @@ export class VoiceTransmitter {
this.isActive = false; this.isActive = false;
this.backpressureQueue = [];
this.draining = false;
if (this.pcmStream) { if (this.pcmStream) {
this.pcmStream.end(); this.pcmStream.end();
this.pcmStream = null; this.pcmStream = null;