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 });