voice: audit + noise suppression toggle + stability fixes
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)
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
|
import Redis from "ioredis";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/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
|
// .subscribe() which converts the connection to subscriber mode. Reusing
|
||||||
// the publish connection from CommandHandler would corrupt it and break
|
// the publish connection from CommandHandler would corrupt it and break
|
||||||
// every command reply + status update.
|
// every command reply + status update.
|
||||||
const { default: IORedis } = await import("ioredis");
|
const transmitRedis = new Redis(config.REDIS_URL);
|
||||||
const transmitRedis = new IORedis(config.REDIS_URL);
|
|
||||||
await voiceTransmitter.start(transmitRedis);
|
await voiceTransmitter.start(transmitRedis);
|
||||||
|
|
||||||
const status = voiceTransmitter.getStatus();
|
const status = voiceTransmitter.getStatus();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export class VoiceTransmitter {
|
|||||||
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
||||||
/** Queue for PCM chunks when backpressure is active */
|
/** Queue for PCM chunks when backpressure is active */
|
||||||
private backpressureQueue: Buffer[] = [];
|
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 */
|
/** Serialise start/stop to prevent races between rapid toggle commands */
|
||||||
private gate = Promise.resolve();
|
private gate = Promise.resolve();
|
||||||
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
||||||
@@ -48,7 +50,6 @@ export class VoiceTransmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.redisSub = redis;
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
|
|
||||||
// Create PCM input stream
|
// Create PCM input stream
|
||||||
@@ -155,7 +156,9 @@ export class VoiceTransmitter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to Redis channel for PCM data
|
// 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(
|
logger.info(
|
||||||
{ channel: this.TRANSMIT_CHANNEL },
|
{ channel: this.TRANSMIT_CHANNEL },
|
||||||
"Subscribed to transmit channel",
|
"Subscribed to transmit channel",
|
||||||
@@ -175,8 +178,19 @@ export class VoiceTransmitter {
|
|||||||
const pcmBuffer = Buffer.from(data.buffer, "base64");
|
const pcmBuffer = Buffer.from(data.buffer, "base64");
|
||||||
const stream = this.pcmStream;
|
const stream = this.pcmStream;
|
||||||
const canContinue = stream.write(pcmBuffer);
|
const canContinue = stream.write(pcmBuffer);
|
||||||
// Backpressure: queue until drain
|
// Backpressure: queue until drain (cap to prevent memory leak)
|
||||||
if (!canContinue) {
|
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", () => {
|
stream.once("drain", () => {
|
||||||
const currentStream = this.pcmStream;
|
const currentStream = this.pcmStream;
|
||||||
if (!currentStream || !this.isActive) return;
|
if (!currentStream || !this.isActive) return;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"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 { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Button, GlassPanel, toast } from "@/components/primitives";
|
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||||
@@ -66,7 +73,19 @@ export function VoiceView({
|
|||||||
tone: next ? "signal" : "neutral",
|
tone: next ? "signal" : "neutral",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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({
|
|||||||
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
|
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-signal transition-all duration-100"
|
className="h-full rounded-full bg-signal transition-all duration-100"
|
||||||
style={{ width: `${Math.min(100, mic.micLevel * 100)}%` }}
|
style={{
|
||||||
|
width: `${Math.min(100, mic.micLevel * 100)}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Noise Suppression Toggle */}
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !mic.noiseSuppression;
|
||||||
|
mic.toggleNoiseSuppression(next);
|
||||||
|
toast({
|
||||||
|
title: next
|
||||||
|
? "Noise suppression ON"
|
||||||
|
: "Noise suppression OFF",
|
||||||
|
tone: next ? "signal" : "neutral",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-all ${
|
||||||
|
mic.noiseSuppression
|
||||||
|
? "bg-success/15 text-success border border-success/30"
|
||||||
|
: "bg-surface-2 text-ink-faint border border-hairline hover:border-ink-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mic.noiseSuppression ? (
|
||||||
|
<ShieldCheck className="size-3" />
|
||||||
|
) : (
|
||||||
|
<ShieldOff className="size-3" />
|
||||||
|
)}
|
||||||
|
NS {mic.noiseSuppression ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
{micActive && (
|
||||||
|
<span className="font-mono text-[9px] text-ink-faint">
|
||||||
|
{mic.noiseSuppression ? "noise gated" : "raw audio"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Listen Toggle */}
|
{/* Listen Toggle */}
|
||||||
@@ -304,19 +359,23 @@ export function VoiceView({
|
|||||||
{/* Per-speaker level meters */}
|
{/* Per-speaker level meters */}
|
||||||
{listenActive && listen.levels.size > 0 && (
|
{listenActive && listen.levels.size > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{Array.from(listen.levels.entries()).map(([hash, level]) => (
|
{Array.from(listen.levels.entries()).map(
|
||||||
<div key={hash} className="flex items-center gap-2">
|
([hash, level]) => (
|
||||||
<span className="font-mono text-[9px] text-ink-faint w-8">
|
<div key={hash} className="flex items-center gap-2">
|
||||||
#{hash.toString(16).slice(-3)}
|
<span className="font-mono text-[9px] text-ink-faint w-8">
|
||||||
</span>
|
#{hash.toString(16).slice(-3)}
|
||||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
</span>
|
||||||
<div
|
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
className="h-full rounded-full bg-success transition-all duration-100"
|
<div
|
||||||
style={{ width: `${Math.min(100, level * 100)}%` }}
|
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>
|
||||||
@@ -326,6 +385,7 @@ export function VoiceView({
|
|||||||
<div className="mt-6 border-t border-hairline pt-3">
|
<div className="mt-6 border-t border-hairline pt-3">
|
||||||
<div className="font-mono text-[10px] text-ink-muted">
|
<div className="font-mono text-[10px] text-ink-muted">
|
||||||
CODEC: OPUS 48KHZ · LOW_LATENCY
|
CODEC: OPUS 48KHZ · LOW_LATENCY
|
||||||
|
{mic.noiseSuppression ? " · NS_ACTIVE" : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -144,10 +144,13 @@ export function useMicTransmit(ws: {
|
|||||||
}) {
|
}) {
|
||||||
const transmitterRef = useRef<MicTransmitter | null>(null);
|
const transmitterRef = useRef<MicTransmitter | null>(null);
|
||||||
const [micLevel, setMicLevel] = useState(0);
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
|
const [noiseSuppression, setNoiseSuppressionState] = useState(true);
|
||||||
|
|
||||||
const action = useAction(async (active: boolean) => {
|
const action = useAction(async (active: boolean) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame));
|
const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame), {
|
||||||
|
noiseSuppression,
|
||||||
|
});
|
||||||
transmitterRef.current = transmitter;
|
transmitterRef.current = transmitter;
|
||||||
await transmitter.start();
|
await transmitter.start();
|
||||||
await voiceApi.sendCommand("voice:transmit:start");
|
await voiceApi.sendCommand("voice:transmit:start");
|
||||||
@@ -163,6 +166,14 @@ export function useMicTransmit(ws: {
|
|||||||
transmitterRef.current?.setVolume(volume / 100);
|
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.
|
// Poll the analyser RMS so the UI can render a live input meter.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -171,7 +182,21 @@ export function useMicTransmit(ws: {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { ...action, setVolume, micLevel };
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
transmitterRef.current?.stop();
|
||||||
|
transmitterRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
setVolume,
|
||||||
|
micLevel,
|
||||||
|
noiseSuppression,
|
||||||
|
toggleNoiseSuppression,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,6 +62,31 @@ class PcmDownsampler extends AudioWorkletProcessor {
|
|||||||
registerProcessor('pcm-downsampler', PcmDownsampler);
|
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 {
|
export class MicTransmitter {
|
||||||
private ctx: AudioContext | null = null;
|
private ctx: AudioContext | null = null;
|
||||||
private stream: MediaStream | null = null;
|
private stream: MediaStream | null = null;
|
||||||
@@ -70,28 +95,82 @@ export class MicTransmitter {
|
|||||||
private levelBuf: Float32Array<ArrayBuffer> | null = null;
|
private levelBuf: Float32Array<ArrayBuffer> | null = null;
|
||||||
private active = false;
|
private active = false;
|
||||||
private volume = 1;
|
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 {
|
get isActive(): boolean {
|
||||||
return this.active;
|
return this.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isNoiseSuppressionEnabled(): boolean {
|
||||||
|
return this.noiseSuppression;
|
||||||
|
}
|
||||||
|
|
||||||
async start(volume = 1): Promise<void> {
|
async start(volume = 1): Promise<void> {
|
||||||
if (this.active) return;
|
if (this.active) return;
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
|
|
||||||
|
// ── Check getUserMedia support ───────────────────────────────────────
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
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({
|
// ── Request mic with noise suppression constraints ───────────────────
|
||||||
audio: {
|
try {
|
||||||
echoCancellation: true,
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
noiseSuppression: true,
|
audio: {
|
||||||
autoGainControl: true,
|
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 });
|
this.ctx = new AudioContext({ sampleRate: 48000 });
|
||||||
|
|
||||||
@@ -153,6 +232,11 @@ export class MicTransmitter {
|
|||||||
this.node?.port.postMessage({ type: "volume", value: volume });
|
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 {
|
stop(): void {
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.node?.port.postMessage({ type: "volume", value: 0 });
|
this.node?.port.postMessage({ type: "volume", value: 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user