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
|
- [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`:
|
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
`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
|
- [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)`,
|
`#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
|
`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.leave` | none | Leaves the current voice channel. |
|
||||||
| `voice.move` | `channelId` | Moves the client to another voice channel. |
|
| `voice.move` | `channelId` | Moves the client to another voice channel. |
|
||||||
| `voice.devices` | none | The client's input and output audio devices. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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_mic` | remap source of `veskslop_tts.monitor` | The client microphone |
|
||||||
| `veskslop_capture` | null sink | Client output, recorded by the daemon |
|
| `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:
|
The host needs a sound server. On Debian and Ubuntu:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -484,6 +484,34 @@ function buildServer(): McpServer {
|
|||||||
jsonText(await pacedVoice(() => requireCore().voiceJoin(channelId, mute ?? true)))
|
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(
|
server.registerTool(
|
||||||
"voice_leave",
|
"voice_leave",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -216,6 +216,19 @@ export class CoreClient {
|
|||||||
return this.request("voice.devices");
|
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) {
|
async voiceSetDevices(inputDeviceId?: string, outputDeviceId?: string) {
|
||||||
return this.request("voice.setDevices", {
|
return this.request("voice.setDevices", {
|
||||||
...(inputDeviceId ? { inputDeviceId } : {}),
|
...(inputDeviceId ? { inputDeviceId } : {}),
|
||||||
|
|||||||
@@ -190,6 +190,15 @@ function handle(action: string | undefined, params: any) {
|
|||||||
userVolumes[userId] = volume;
|
userVolumes[userId] = volume;
|
||||||
return { userId, volume, outputVolume };
|
return { userId, volume, outputVolume };
|
||||||
}
|
}
|
||||||
|
case "voice.processing":
|
||||||
|
return {
|
||||||
|
noiseSuppression: false,
|
||||||
|
echoCancellation: false,
|
||||||
|
automaticGainControl: false,
|
||||||
|
noiseCancellation: false,
|
||||||
|
inputVolume: null
|
||||||
|
};
|
||||||
|
|
||||||
case "voice.setDevices":
|
case "voice.setDevices":
|
||||||
return { inputDeviceId: params?.inputDeviceId ?? null, outputDeviceId: params?.outputDeviceId ?? null };
|
return { inputDeviceId: params?.inputDeviceId ?? null, outputDeviceId: params?.outputDeviceId ?? null };
|
||||||
case "user.search": {
|
case "user.search": {
|
||||||
|
|||||||
@@ -59,6 +59,101 @@ const UserVolumeActions = findByPropsLazy("setUserVolume");
|
|||||||
/** Media engine store, for the client's own output volume. */
|
/** Media engine store, for the client's own output volume. */
|
||||||
const MediaEngineStore = findStoreLazy("MediaEngineStore");
|
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 {
|
function userVolumeOf(userId: string): number | null {
|
||||||
const candidates: any[] = [UserVolumeStore, UserVolumeActions, MediaEngineStore, MediaEngineActions];
|
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"
|
"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) {
|
if (action === PuppetAction.VoiceJoin && mute !== false) {
|
||||||
const setSelfMute = (MediaEngineActions as any)?.setSelfMute;
|
const setSelfMute = (MediaEngineActions as any)?.setSelfMute;
|
||||||
if (typeof setSelfMute === "function") {
|
if (typeof setSelfMute === "function") {
|
||||||
@@ -709,7 +813,8 @@ export async function handlePuppetAction(action: string, params: Record<string,
|
|||||||
return ok({
|
return ok({
|
||||||
channelId,
|
channelId,
|
||||||
joined: true,
|
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: {
|
case PuppetAction.UserSearch: {
|
||||||
const { query, guildId, limit = 10 } = params;
|
const { query, guildId, limit = 10 } = params;
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const enum PuppetAction {
|
|||||||
UserSetVolume = "user.setVolume",
|
UserSetVolume = "user.setVolume",
|
||||||
VoiceDevices = "voice.devices",
|
VoiceDevices = "voice.devices",
|
||||||
VoiceSetDevices = "voice.setDevices",
|
VoiceSetDevices = "voice.setDevices",
|
||||||
|
VoiceProcessing = "voice.processing",
|
||||||
UserSearch = "user.search",
|
UserSearch = "user.search",
|
||||||
DmList = "dm.list",
|
DmList = "dm.list",
|
||||||
MessageSend = "message.send",
|
MessageSend = "message.send",
|
||||||
@@ -53,6 +54,7 @@ export const PuppetActions: readonly PuppetAction[] = [
|
|||||||
PuppetAction.UserSetVolume,
|
PuppetAction.UserSetVolume,
|
||||||
PuppetAction.VoiceDevices,
|
PuppetAction.VoiceDevices,
|
||||||
PuppetAction.VoiceSetDevices,
|
PuppetAction.VoiceSetDevices,
|
||||||
|
PuppetAction.VoiceProcessing,
|
||||||
PuppetAction.UserSearch,
|
PuppetAction.UserSearch,
|
||||||
PuppetAction.DmList,
|
PuppetAction.DmList,
|
||||||
PuppetAction.MessageSend,
|
PuppetAction.MessageSend,
|
||||||
|
|||||||
Reference in New Issue
Block a user