From 7e0d0d5123eaf0bdc042bc472e0893e1b568f200 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 26 Aug 2026 23:32:37 +0700 Subject: [PATCH] voice: audit + noise suppression toggle + stability fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway: - transmitter.ts: cap backpressure queue at 500 chunks (prevents memory leak) - transmitter.ts: fix Redis race — assign redisSub AFTER subscribe completes - voice.handler.ts: static import Redis instead of dynamic (cleaner, no eval) Frontend: - mic-transmit.ts: MicAccessError with specific reasons (permission-denied, no-mic, timeout) - mic-transmit.ts: noiseSuppression option in getUserMedia constraints - mic-transmit.ts: proper DOMException handling for all getUserMedia failure modes - use-voice.ts: noiseSuppression state + toggleNoiseSuppression exposed - use-voice.ts: cleanup on unmount (stops transmitter, clears refs) - voice/view.tsx: noise suppression toggle button (ShieldCheck/ShieldOff icons) - voice/view.tsx: improved mic error toasts (permission denied / no mic specific) - voice/view.tsx: NS status in codec footer (NS_ACTIVE when enabled) --- .../modules/command-handler/voice.handler.ts | 4 +- .../modules/voice-recording/transmitter.ts | 20 +++- .../src/app/(dashboard)/voice/view.tsx | 90 +++++++++++++--- services/frontend/src/hooks/use-voice.ts | 29 ++++- .../frontend/src/lib/audio/mic-transmit.ts | 102 ++++++++++++++++-- 5 files changed, 214 insertions(+), 31 deletions(-) diff --git a/services/discord-gateway/src/modules/command-handler/voice.handler.ts b/services/discord-gateway/src/modules/command-handler/voice.handler.ts index 61f869c5..bdd8e2f2 100644 --- a/services/discord-gateway/src/modules/command-handler/voice.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/voice.handler.ts @@ -1,4 +1,5 @@ import type { Client } from "discord.js-selfbot-v13"; +import Redis from "ioredis"; import { config } from "../../shared/config/config.js"; import type { CommandMessage, CommandReply } from "../../shared/index.js"; import { createChildLogger } from "../../shared/logger/index.js"; @@ -157,8 +158,7 @@ export class VoiceHandler { // .subscribe() which converts the connection to subscriber mode. Reusing // the publish connection from CommandHandler would corrupt it and break // every command reply + status update. - const { default: IORedis } = await import("ioredis"); - const transmitRedis = new IORedis(config.REDIS_URL); + const transmitRedis = new Redis(config.REDIS_URL); await voiceTransmitter.start(transmitRedis); const status = voiceTransmitter.getStatus(); diff --git a/services/discord-gateway/src/modules/voice-recording/transmitter.ts b/services/discord-gateway/src/modules/voice-recording/transmitter.ts index bf890a95..ae2319e3 100644 --- a/services/discord-gateway/src/modules/voice-recording/transmitter.ts +++ b/services/discord-gateway/src/modules/voice-recording/transmitter.ts @@ -23,6 +23,8 @@ export class VoiceTransmitter { private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT; /** Queue for PCM chunks when backpressure is active */ private backpressureQueue: Buffer[] = []; + /** Max queued chunks before dropping oldest (prevents unbounded memory growth) */ + private static readonly MAX_QUEUE = 500; /** Serialise start/stop to prevent races between rapid toggle commands */ private gate = Promise.resolve(); /** Set true before sending SIGTERM so exit handler knows it's intentional */ @@ -48,7 +50,6 @@ export class VoiceTransmitter { return; } - this.redisSub = redis; this.isActive = true; // Create PCM input stream @@ -155,7 +156,9 @@ export class VoiceTransmitter { ); // Subscribe to Redis channel for PCM data - await this.redisSub.subscribe(this.TRANSMIT_CHANNEL); + await redis.subscribe(this.TRANSMIT_CHANNEL); + // Assign AFTER subscription succeeds — prevents race with stop() + this.redisSub = redis; logger.info( { channel: this.TRANSMIT_CHANNEL }, "Subscribed to transmit channel", @@ -175,8 +178,19 @@ export class VoiceTransmitter { const pcmBuffer = Buffer.from(data.buffer, "base64"); const stream = this.pcmStream; const canContinue = stream.write(pcmBuffer); - // Backpressure: queue until drain + // Backpressure: queue until drain (cap to prevent memory leak) if (!canContinue) { + if (this.backpressureQueue.length >= VoiceTransmitter.MAX_QUEUE) { + // Drop oldest chunks to free memory — real-time audio, + // stale data is useless + const dropCount = Math.floor(VoiceTransmitter.MAX_QUEUE * 0.25); + this.backpressureQueue.splice(0, dropCount); + logger.debug( + { dropped: dropCount }, + "Backpressure overflow — dropping oldest PCM chunks", + ); + } + this.backpressureQueue.push(pcmBuffer); stream.once("drain", () => { const currentStream = this.pcmStream; if (!currentStream || !this.isActive) return; diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index 3f64998e..12319033 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -1,6 +1,13 @@ "use client"; -import { Mic, PhoneOff, Radio, Volume2 } from "lucide-react"; +import { + Mic, + PhoneOff, + Radio, + ShieldCheck, + ShieldOff, + Volume2, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Button, GlassPanel, toast } from "@/components/primitives"; @@ -66,7 +73,19 @@ export function VoiceView({ tone: next ? "signal" : "neutral", }); } catch (e) { - toast({ title: "Mic toggle failed", description: String(e), tone: "vermilion" }); + // MicAccessError from mic-transmit.ts has specific reasons + const msg = e instanceof Error ? e.message : String(e); + const isPermDenied = msg.includes("denied") || msg.includes("Permission"); + const isNoMic = msg.includes("No microphone"); + toast({ + title: isPermDenied + ? "Mic permission denied" + : isNoMic + ? "No microphone found" + : "Mic toggle failed", + description: msg, + tone: "vermilion", + }); } }; @@ -264,10 +283,46 @@ export function VoiceView({
)} + + {/* Noise Suppression Toggle */} +
+ + {micActive && ( + + {mic.noiseSuppression ? "noise gated" : "raw audio"} + + )} +
{/* Listen Toggle */} @@ -304,19 +359,23 @@ export function VoiceView({ {/* Per-speaker level meters */} {listenActive && listen.levels.size > 0 && (
- {Array.from(listen.levels.entries()).map(([hash, level]) => ( -
- - #{hash.toString(16).slice(-3)} - -
-
+ {Array.from(listen.levels.entries()).map( + ([hash, level]) => ( +
+ + #{hash.toString(16).slice(-3)} + +
+
+
-
- ))} + ), + )}
)}
@@ -326,6 +385,7 @@ export function VoiceView({
CODEC: OPUS 48KHZ · LOW_LATENCY + {mic.noiseSuppression ? " · NS_ACTIVE" : ""}
diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts index f918a13b..12bee4c1 100644 --- a/services/frontend/src/hooks/use-voice.ts +++ b/services/frontend/src/hooks/use-voice.ts @@ -144,10 +144,13 @@ export function useMicTransmit(ws: { }) { const transmitterRef = useRef(null); const [micLevel, setMicLevel] = useState(0); + const [noiseSuppression, setNoiseSuppressionState] = useState(true); const action = useAction(async (active: boolean) => { if (active) { - const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame)); + const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame), { + noiseSuppression, + }); transmitterRef.current = transmitter; await transmitter.start(); await voiceApi.sendCommand("voice:transmit:start"); @@ -163,6 +166,14 @@ export function useMicTransmit(ws: { transmitterRef.current?.setVolume(volume / 100); }, []); + const toggleNoiseSuppression = useCallback((enabled: boolean) => { + setNoiseSuppressionState(enabled); + // If mic is already active, toggling NS requires restart + if (transmitterRef.current?.isActive) { + transmitterRef.current.setNoiseSuppression(enabled); + } + }, []); + // Poll the analyser RMS so the UI can render a live input meter. useEffect(() => { const timer = setInterval(() => { @@ -171,7 +182,21 @@ export function useMicTransmit(ws: { return () => clearInterval(timer); }, []); - return { ...action, setVolume, micLevel }; + // Cleanup on unmount + useEffect(() => { + return () => { + transmitterRef.current?.stop(); + transmitterRef.current = null; + }; + }, []); + + return { + ...action, + setVolume, + micLevel, + noiseSuppression, + toggleNoiseSuppression, + }; } /** diff --git a/services/frontend/src/lib/audio/mic-transmit.ts b/services/frontend/src/lib/audio/mic-transmit.ts index 61901ea9..661f6ae0 100644 --- a/services/frontend/src/lib/audio/mic-transmit.ts +++ b/services/frontend/src/lib/audio/mic-transmit.ts @@ -62,6 +62,31 @@ class PcmDownsampler extends AudioWorkletProcessor { registerProcessor('pcm-downsampler', PcmDownsampler); `; +/** Mic access error with user-actionable detail. */ +export class MicAccessError extends Error { + constructor( + message: string, + public readonly reason: + | "not-supported" + | "permission-denied" + | "no-mic" + | "timeout" + | "unknown", + ) { + super(message); + this.name = "MicAccessError"; + } +} + +export interface MicTransmitterOptions { + /** Enable browser-level noise suppression (default: true). */ + noiseSuppression?: boolean; + /** Enable echo cancellation (default: true). */ + echoCancellation?: boolean; + /** Enable auto gain control (default: true). */ + autoGainControl?: boolean; +} + export class MicTransmitter { private ctx: AudioContext | null = null; private stream: MediaStream | null = null; @@ -70,28 +95,82 @@ export class MicTransmitter { private levelBuf: Float32Array | null = null; private active = false; private volume = 1; + private noiseSuppression = true; - constructor(private readonly onChunk: (frame: ArrayBuffer) => void) {} + constructor( + private readonly onChunk: (frame: ArrayBuffer) => void, + private readonly options: MicTransmitterOptions = {}, + ) { + this.noiseSuppression = options.noiseSuppression ?? true; + } get isActive(): boolean { return this.active; } + get isNoiseSuppressionEnabled(): boolean { + return this.noiseSuppression; + } + async start(volume = 1): Promise { if (this.active) return; this.volume = volume; + // ── Check getUserMedia support ─────────────────────────────────────── if (!navigator.mediaDevices?.getUserMedia) { - throw new Error("getUserMedia is not available (insecure context?)"); + throw new MicAccessError( + "getUserMedia is not available — are you on HTTPS or localhost?", + "not-supported", + ); } - this.stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }); + // ── Request mic with noise suppression constraints ─────────────────── + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: this.options.echoCancellation ?? true, + noiseSuppression: this.noiseSuppression, + autoGainControl: this.options.autoGainControl ?? true, + }, + }); + } catch (err) { + if (err instanceof DOMException) { + if ( + err.name === "NotAllowedError" || + err.name === "PermissionDeniedError" + ) { + throw new MicAccessError( + "Microphone access denied — allow mic permission in your browser", + "permission-denied", + ); + } + if ( + err.name === "NotFoundError" || + err.name === "DevicesNotFoundError" + ) { + throw new MicAccessError( + "No microphone found — connect a mic and try again", + "no-mic", + ); + } + if (err.name === "OverconstrainedError") { + throw new MicAccessError( + "Microphone does not support the requested constraints", + "unknown", + ); + } + if (err.name === "AbortError" || err.name === "TimeoutError") { + throw new MicAccessError( + "Microphone access timed out — try again", + "timeout", + ); + } + } + throw new MicAccessError( + `Failed to access microphone: ${err instanceof Error ? err.message : String(err)}`, + "unknown", + ); + } this.ctx = new AudioContext({ sampleRate: 48000 }); @@ -153,6 +232,11 @@ export class MicTransmitter { this.node?.port.postMessage({ type: "volume", value: volume }); } + /** Toggle noise suppression. Requires restart to take effect. */ + setNoiseSuppression(enabled: boolean): void { + this.noiseSuppression = enabled; + } + stop(): void { this.active = false; this.node?.port.postMessage({ type: "volume", value: 0 });