Turn Discord mic processing off for TTS playback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user