Turn Discord mic processing off for TTS playback

This commit is contained in:
Fisto
2026-09-21 18:13:28 +02:00
parent 15901849a1
commit 17d4339ea3
8 changed files with 207 additions and 1 deletions
+7
View File
@@ -40,6 +40,13 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C71 Voice processing gates off. The TTS line was ramping in because Discord's automatic
gain control (and echo/noise cancellation) were on. New `voice.processing` core action reads and
sets the gates (defaults off) plus an optional input volume; MCP `set_voice_processing` exposes
it, and `voice.join` disables all supported gates before connecting. Live check on the real
client: before = echoCancellation true, automaticGainControl true, noiseCancellation true,
noiseSuppression false; after = all false. Note these settings are client-local, not account
settings.
- [x] C70 Speak panel honesty. Two bugs behind "speak stays queued": the panel never updated
`#tts-status` after queuing (now it polls the daemon and shows `speaking…`, `queued (N)`,
`done`, then `idle`), and `/api/audio/speak` turned the panel's `null` option fields into 0 via
+1
View File
@@ -63,6 +63,7 @@ Event line:
| `voice.leave` | none | Leaves the current voice channel. |
| `voice.move` | `channelId` | Moves the client to another voice channel. |
| `voice.devices` | none | The client's input and output audio devices. |
| `voice.processing` | optional `noiseSuppression`, `echoCancellation`, `automaticGainControl`, `noiseCancellation`, `inputVolume` | Reads and sets Discord's microphone processing gates. Omitting the booleans turns every supported gate off; returns the previous and current state. Voice joins apply it automatically. |
| `voice.setDevices` | optional `inputDeviceId`, optional `outputDeviceId` | Sets the client devices. |
| `user.search` | `query`, optional `guildId`, optional `limit` | Users from the client cache. Guild members when `guildId` is set. |
| `dm.list` | optional `limit` | Cached DM and group DM channels with recipients, icons, and avatars. Cache-only. |
+6
View File
@@ -55,6 +55,12 @@ Voice audio uses the system audio stack, not Discord internals. The daemon owns
| `veskslop_mic` | remap source of `veskslop_tts.monitor` | The client microphone |
| `veskslop_capture` | null sink | Client output, recorded by the daemon |
Discord's own microphone processing fights a TTS line: **automatic gain control** ramps the volume
up over the first words (so the start is quiet or clipped), and echo/noise cancellation gates the
first syllable. The core reads and turns those gates off (`voice.processing`; MCP
`set_voice_processing`), and every `voice.join` applies it automatically before connecting. Discord
stores these per client, not per account, so a user's own app is unaffected.
The host needs a sound server. On Debian and Ubuntu:
```sh
+28
View File
@@ -484,6 +484,34 @@ function buildServer(): McpServer {
jsonText(await pacedVoice(() => requireCore().voiceJoin(channelId, mute ?? true)))
);
server.registerTool(
"set_voice_processing",
{
description:
"Read or change Discord's microphone processing. Defaults turn noise suppression, " +
"echo cancellation, automatic gain control, and noise cancellation off, which stops " +
"the call from ramping in and clipping the first words of TTS. Voice joins already " +
"do this automatically.",
inputSchema: {
noiseSuppression: z.boolean().optional(),
echoCancellation: z.boolean().optional(),
automaticGainControl: z.boolean().optional(),
noiseCancellation: z.boolean().optional(),
inputVolume: z.number().min(0).max(200).optional().describe("Discord input volume percent")
}
},
async ({ noiseSuppression, echoCancellation, automaticGainControl, noiseCancellation, inputVolume }) =>
jsonText(
await requireCore().voiceProcessing({
...(noiseSuppression !== undefined ? { noiseSuppression } : {}),
...(echoCancellation !== undefined ? { echoCancellation } : {}),
...(automaticGainControl !== undefined ? { automaticGainControl } : {}),
...(noiseCancellation !== undefined ? { noiseCancellation } : {}),
...(inputVolume !== undefined ? { inputVolume } : {})
})
)
);
server.registerTool(
"voice_leave",
{
+13
View File
@@ -216,6 +216,19 @@ export class CoreClient {
return this.request("voice.devices");
}
/** Read/turn off Discord's mic processing gates (noise suppression, AGC, echo). */
async voiceProcessing(
settings: {
noiseSuppression?: boolean;
echoCancellation?: boolean;
automaticGainControl?: boolean;
noiseCancellation?: boolean;
inputVolume?: number;
} = {}
) {
return this.request("voice.processing", settings);
}
async voiceSetDevices(inputDeviceId?: string, outputDeviceId?: string) {
return this.request("voice.setDevices", {
...(inputDeviceId ? { inputDeviceId } : {}),
+9
View File
@@ -190,6 +190,15 @@ function handle(action: string | undefined, params: any) {
userVolumes[userId] = volume;
return { userId, volume, outputVolume };
}
case "voice.processing":
return {
noiseSuppression: false,
echoCancellation: false,
automaticGainControl: false,
noiseCancellation: false,
inputVolume: null
};
case "voice.setDevices":
return { inputDeviceId: params?.inputDeviceId ?? null, outputDeviceId: params?.outputDeviceId ?? null };
case "user.search": {
+141 -1
View File
@@ -59,6 +59,101 @@ const UserVolumeActions = findByPropsLazy("setUserVolume");
/** Media engine store, for the client's own output volume. */
const MediaEngineStore = findStoreLazy("MediaEngineStore");
/**
* Discord's voice processing gates and ramps the microphone: noise suppression (Krisp) and
* automatic gain control can swallow the start of a TTS line. The setters live beside
* setSelfMute in newer builds and on the store in older ones.
*/
const VOICE_PROCESSING = {
noiseSuppression: "setNoiseSuppression",
echoCancellation: "setEchoCancellation",
automaticGainControl: "setAutomaticGainControl",
noiseCancellation: "setNoiseCancellation"
} as const;
type VoiceProcessingKey = keyof typeof VOICE_PROCESSING;
function voiceProcessingTargets(): any[] {
const targets: any[] = [];
for (const candidate of [MediaEngineActions, MediaEngineStore]) {
try {
if (candidate) targets.push(candidate);
} catch {
// The lazy proxy throws when the module is absent.
}
}
return targets;
}
/** Turn the named processing gates on or off where the client supports them. */
function applyVoiceProcessing(
values: Partial<Record<VoiceProcessingKey, boolean>>
): Partial<Record<VoiceProcessingKey, boolean | null>> {
const applied: Partial<Record<VoiceProcessingKey, boolean | null>> = {};
for (const [key, setterName] of Object.entries(VOICE_PROCESSING) as Array<[VoiceProcessingKey, string]>) {
if (!(key in values)) continue;
const wanted = values[key] === true;
applied[key] = null;
for (const target of voiceProcessingTargets()) {
const setter = target?.[setterName];
if (typeof setter !== "function") continue;
try {
setter.call(target, wanted);
applied[key] = wanted;
} catch {
// Best effort: a build may expose the setter but reject the call.
}
break;
}
}
return applied;
}
/** Read the current processing gates, when the client exposes getters. */
function readVoiceProcessing(): Partial<Record<VoiceProcessingKey, boolean | null>> {
const state: Partial<Record<VoiceProcessingKey, boolean | null>> = {};
for (const [key, setterName] of Object.entries(VOICE_PROCESSING) as Array<[VoiceProcessingKey, string]>) {
const getterName = setterName.replace(/^set/, "get");
state[key] = null;
for (const target of voiceProcessingTargets()) {
const getter = target?.[getterName];
if (typeof getter !== "function") continue;
try {
const raw = getter.call(target);
state[key] = typeof raw === "boolean" ? raw : null;
} catch {
// Not readable in this build.
}
break;
}
}
return state;
}
function setInputVolume(volume: number): number | null {
for (const target of voiceProcessingTargets()) {
const setter = target?.setInputVolume;
if (typeof setter !== "function") continue;
try {
setter.call(target, volume);
return volume;
} catch {
return null;
}
}
return null;
}
function userVolumeOf(userId: string): number | null {
const candidates: any[] = [UserVolumeStore, UserVolumeActions, MediaEngineStore, MediaEngineActions];
@@ -677,6 +772,15 @@ export async function handlePuppetAction(action: string, params: Record<string,
"voice actions are not available in this client"
);
// Discord's noise suppression and AGC ramp the mic and eat the first words
// of a TTS line; joins disable them before the connection is made.
const processing = applyVoiceProcessing({
noiseSuppression: false,
echoCancellation: false,
automaticGainControl: false,
noiseCancellation: false
});
if (action === PuppetAction.VoiceJoin && mute !== false) {
const setSelfMute = (MediaEngineActions as any)?.setSelfMute;
if (typeof setSelfMute === "function") {
@@ -709,7 +813,8 @@ export async function handlePuppetAction(action: string, params: Record<string,
return ok({
channelId,
joined: true,
muted: action === PuppetAction.VoiceJoin ? mute !== false : undefined
muted: action === PuppetAction.VoiceJoin ? mute !== false : undefined,
processing
});
}
@@ -785,6 +890,41 @@ export async function handlePuppetAction(action: string, params: Record<string,
});
}
case PuppetAction.VoiceProcessing: {
const wanted: Partial<Record<VoiceProcessingKey, boolean>> = {};
for (const key of Object.keys(VOICE_PROCESSING) as VoiceProcessingKey[]) {
const value = params[key];
if (value !== undefined) {
assert(typeof value === "boolean", PuppetErrorCode.InvalidParam, `${key} must be a boolean`);
wanted[key] = value;
}
}
// Calling without explicit values turns every supported gate off.
if (!Object.keys(wanted).length) {
for (const key of Object.keys(VOICE_PROCESSING) as VoiceProcessingKey[]) {
wanted[key] = false;
}
}
const before = readVoiceProcessing();
const applied = applyVoiceProcessing(wanted);
let inputVolume: number | null = null;
if (params.inputVolume !== undefined) {
const volume = Number(params.inputVolume);
assert(
Number.isFinite(volume) && volume >= 0 && volume <= 200,
PuppetErrorCode.InvalidParam,
"inputVolume must be 0-200"
);
inputVolume = setInputVolume(volume);
}
return ok({ before, ...readVoiceProcessing(), ...applied, inputVolume });
}
case PuppetAction.UserSearch: {
const { query, guildId, limit = 10 } = params;
assert(
+2
View File
@@ -28,6 +28,7 @@ export const enum PuppetAction {
UserSetVolume = "user.setVolume",
VoiceDevices = "voice.devices",
VoiceSetDevices = "voice.setDevices",
VoiceProcessing = "voice.processing",
UserSearch = "user.search",
DmList = "dm.list",
MessageSend = "message.send",
@@ -53,6 +54,7 @@ export const PuppetActions: readonly PuppetAction[] = [
PuppetAction.UserSetVolume,
PuppetAction.VoiceDevices,
PuppetAction.VoiceSetDevices,
PuppetAction.VoiceProcessing,
PuppetAction.UserSearch,
PuppetAction.DmList,
PuppetAction.MessageSend,