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 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Listen Toggle */}
|
||||
@@ -304,7 +359,8 @@ export function VoiceView({
|
||||
{/* Per-speaker level meters */}
|
||||
{listenActive && listen.levels.size > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Array.from(listen.levels.entries()).map(([hash, level]) => (
|
||||
{Array.from(listen.levels.entries()).map(
|
||||
([hash, level]) => (
|
||||
<div key={hash} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[9px] text-ink-faint w-8">
|
||||
#{hash.toString(16).slice(-3)}
|
||||
@@ -312,11 +368,14 @@ export function VoiceView({
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-success transition-all duration-100"
|
||||
style={{ width: `${Math.min(100, level * 100)}%` }}
|
||||
style={{
|
||||
width: `${Math.min(100, level * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -326,6 +385,7 @@ export function VoiceView({
|
||||
<div className="mt-6 border-t border-hairline pt-3">
|
||||
<div className="font-mono text-[10px] text-ink-muted">
|
||||
CODEC: OPUS 48KHZ · LOW_LATENCY
|
||||
{mic.noiseSuppression ? " · NS_ACTIVE" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
@@ -144,10 +144,13 @@ export function useMicTransmit(ws: {
|
||||
}) {
|
||||
const transmitterRef = useRef<MicTransmitter | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ArrayBuffer> | 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<void> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Request mic with noise suppression constraints ───────────────────
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user