chore(services): update components based on recent changes
Changes: services/discord-gateway/src/modules/voice-pcm-ws/index.ts | 1 + services/discord-gateway/src/modules/voice-pcm-ws/voicePcmWsClient.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { VoicePcmWsClient } from "./voicePcmWsClient.js";
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Direct WebSocket client for streaming voice PCM to the backend.
|
||||
*
|
||||
* Bypasses Redis pub/sub entirely — gateway connects as a WS client
|
||||
* to the backend's existing WebSocket server and sends pre-formatted
|
||||
* binary frames (4-byte FNV-1a userId hash + raw Int16LE PCM).
|
||||
* The backend forwards these unchanged to frontend clients.
|
||||
*/
|
||||
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const logger = createChildLogger("voice-pcm-ws");
|
||||
|
||||
/** 32-bit FNV-1a hash — mirrors backend/ws/redis-bridge.ts and frontend useAudioPlayback.ts */
|
||||
function hashUserId(userId: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash ^= userId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export class VoicePcmWsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private readonly token: string;
|
||||
private readonly url: string;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private closed = false;
|
||||
private reconnectMs = 1000;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** Open connection and begin automatic reconnect loop. */
|
||||
connect(): void {
|
||||
if (this.closed) return;
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM chunk to the backend.
|
||||
*
|
||||
* Binary frame format (identical to what frontend expects):
|
||||
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
|
||||
* Byte 4+: PCM audio (24kHz mono Int16LE)
|
||||
*
|
||||
* Silently drops if not connected — real-time audio, stale
|
||||
* chunks from a disconnect gap are useless.
|
||||
*/
|
||||
sendPcm(userId: string, pcmBuffer: Buffer): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const hash = hashUserId(userId);
|
||||
const frame = Buffer.alloc(4 + pcmBuffer.length);
|
||||
frame.writeUInt32LE(hash, 0);
|
||||
pcmBuffer.copy(frame, 4);
|
||||
|
||||
try {
|
||||
this.ws.send(frame);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send PCM frame");
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful close — send close frame, clear reconnect timer. */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (!this.ws) return;
|
||||
|
||||
// Remove listeners so reconnect doesn't fire during close
|
||||
this.ws.removeAllListeners();
|
||||
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close(1000, "shutdown");
|
||||
}
|
||||
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────
|
||||
|
||||
private getUrlWithAuth(): string {
|
||||
const sep = this.url.includes("?") ? "&" : "?";
|
||||
return `${this.url}${sep}token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
|
||||
private doConnect(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
const authUrl = this.getUrlWithAuth();
|
||||
logger.info({ url: this.url }, "Connecting to backend WebSocket");
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(authUrl);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to create WebSocket");
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.on("open", () => {
|
||||
logger.info("Voice PCM WS client connected");
|
||||
this.reconnectMs = 1000; // reset backoff on successful connect
|
||||
});
|
||||
|
||||
this.ws.on("close", (code, reason) => {
|
||||
logger.warn(
|
||||
{ code, reason: reason.toString() },
|
||||
"Voice PCM WS client disconnected",
|
||||
);
|
||||
this.ws = null;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
this.ws.on("error", (err) => {
|
||||
logger.error({ err: err.message }, "Voice PCM WS client error");
|
||||
// on("close") fires after on("error") — reconnect handled there
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed) return;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
// Exponential backoff with jitter: 1s → 2s → 4s → ... → 30s cap
|
||||
const delay = this.reconnectMs + Math.random() * 1000;
|
||||
logger.info({ delayMs: Math.round(delay) }, "Scheduling WS reconnect");
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.closed) {
|
||||
this.doConnect();
|
||||
}
|
||||
}, delay);
|
||||
|
||||
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user