From 17d4339ea3346d6a312d99fa8b0741ccaa304bc3 Mon Sep 17 00:00:00 2001 From: Fisto Date: Mon, 21 Sep 2026 18:13:28 +0200 Subject: [PATCH] Turn Discord mic processing off for TTS playback --- CURRENT.md | 7 ++ docs/core-bridge.md | 1 + docs/voice.md | 6 ++ poc/src/control-bridge.ts | 28 +++++++ poc/src/core-client.ts | 13 +++ poc/src/mock-core.ts | 9 +++ src/renderer/puppet/actions.ts | 142 ++++++++++++++++++++++++++++++++- src/shared/puppetProtocol.ts | 2 + 8 files changed, 207 insertions(+), 1 deletion(-) diff --git a/CURRENT.md b/CURRENT.md index f649e18..08be186 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -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///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 diff --git a/docs/core-bridge.md b/docs/core-bridge.md index dda6793..88a0ec1 100644 --- a/docs/core-bridge.md +++ b/docs/core-bridge.md @@ -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. | diff --git a/docs/voice.md b/docs/voice.md index ab569cf..cde0e71 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -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 diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 14f28a0..89c6eca 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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", { diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts index 303494c..f4bd256 100644 --- a/poc/src/core-client.ts +++ b/poc/src/core-client.ts @@ -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 } : {}), diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts index 18f642f..f079416 100644 --- a/poc/src/mock-core.ts +++ b/poc/src/mock-core.ts @@ -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": { diff --git a/src/renderer/puppet/actions.ts b/src/renderer/puppet/actions.ts index f0b24c9..aa0b8bf 100644 --- a/src/renderer/puppet/actions.ts +++ b/src/renderer/puppet/actions.ts @@ -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> +): Partial> { + const applied: Partial> = {}; + + 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> { + const state: Partial> = {}; + + 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> = {}; + + 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( diff --git a/src/shared/puppetProtocol.ts b/src/shared/puppetProtocol.ts index a29e1c4..cbc3f0b 100644 --- a/src/shared/puppetProtocol.ts +++ b/src/shared/puppetProtocol.ts @@ -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,