Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls

This commit is contained in:
Fisto
2026-09-15 15:09:23 +02:00
parent eab94132bb
commit 302436f95f
42 changed files with 4675 additions and 180 deletions
+26 -5
View File
@@ -12,6 +12,9 @@ import { mainWin } from "./mainWindow";
const resolvers = new Map<string, Record<"resolve" | "reject", (data: any) => void>>();
/** Renderer commands that never answer must not leak their resolver or hang the caller. */
const RENDERER_TIMEOUT_MS = 15_000;
export interface IpcMessage {
nonce: string;
message: string;
@@ -31,18 +34,36 @@ export interface IpcResponse {
* You must add a handler for the message in the renderer process.
*/
export function sendRendererCommand<T = any>(message: string, data?: any) {
if (mainWin.isDestroyed()) {
console.warn("Main window is destroyed, cannot send IPC command:", message);
return Promise.reject(new Error("Main window is destroyed"));
if (mainWin.isDestroyed() || mainWin.webContents.isDestroyed() || mainWin.webContents.isCrashed()) {
return Promise.reject(new Error("the client renderer is not available"));
}
const nonce = randomUUID();
const promise = new Promise<T>((resolve, reject) => {
resolvers.set(nonce, { resolve, reject });
const timer = setTimeout(() => {
resolvers.delete(nonce);
reject(new Error(`the renderer did not answer ${message} in time`));
}, RENDERER_TIMEOUT_MS);
resolvers.set(nonce, {
resolve: value => {
clearTimeout(timer);
resolve(value);
},
reject: error => {
clearTimeout(timer);
reject(error);
}
});
});
mainWin.webContents.send(IpcEvents.IPC_COMMAND, { nonce, message, data });
try {
mainWin.webContents.send(IpcEvents.IPC_COMMAND, { nonce, message, data });
} catch (error) {
resolvers.delete(nonce);
return Promise.reject(error);
}
return promise;
}
+8 -1
View File
@@ -255,7 +255,14 @@ function initSettingsListeners(win: BrowserWindow) {
}
async function initSpellCheckLanguages(win: BrowserWindow, languages?: string[]) {
languages ??= await sendRendererCommand(IpcCommands.GET_LANGUAGES);
if (!languages) {
try {
languages = await sendRendererCommand(IpcCommands.GET_LANGUAGES);
} catch {
// The renderer may be loading or gone; spell check keeps its default.
return;
}
}
if (!languages) return;
const ses = session.defaultSession;
+19 -22
View File
@@ -21,6 +21,7 @@ import { IpcCommands, IpcEvents } from "shared/IpcEvents";
import {
PUPPET_LIMITS,
PuppetAction,
PuppetActions,
PuppetErrorCode,
type PuppetEvent,
type PuppetRequest,
@@ -36,30 +37,13 @@ export const PUPPET_SOCKET_PATH =
? "\\\\?\\pipe\\vesktop-puppet.sock"
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop-puppet.sock");
const KNOWN_ACTIONS = new Set<string>([
PuppetAction.Ping,
PuppetAction.EventsSubscribe,
PuppetAction.StateGet,
PuppetAction.VoiceGet,
PuppetAction.VoiceOverview,
PuppetAction.VoiceJoin,
PuppetAction.VoiceLeave,
PuppetAction.VoiceMove,
PuppetAction.VoiceUsers,
PuppetAction.UserStatus,
PuppetAction.VoiceDevices,
PuppetAction.VoiceSetDevices,
PuppetAction.UserSearch,
PuppetAction.DmList,
PuppetAction.MessageSend,
PuppetAction.MessageRead,
PuppetAction.MessageFetch,
PuppetAction.ReactionAdd,
PuppetAction.DmSend
]);
// Derived from the shared list so a new action cannot be added in the protocol
// yet rejected by the bridge.
const KNOWN_ACTIONS = new Set<string>(PuppetActions);
let server: Server | undefined;
const subscribers = new Set<Socket>();
let rendererWarned = false;
export async function initPuppetBridge() {
await rm(PUPPET_SOCKET_PATH, { force: true });
@@ -148,11 +132,24 @@ async function handleLine(socket: Socket, line: string) {
try {
const result = await sendRendererCommand<PuppetResult>(IpcCommands.PUPPET_COMMAND, { action, params });
rendererWarned = false;
if (result?.ok === false) respond(socket, { id, ok: false, error: result.error });
else respond(socket, { id, ok: true, result: result?.result });
} catch (error) {
console.error("[puppet] action failed:", action, error);
const message = error instanceof Error ? error.message : String(error);
// A dead renderer is expected during a restart; the daemon polls every
// second, so log the outage once instead of once per command.
if (message === "the client renderer is not available") {
if (!rendererWarned) {
rendererWarned = true;
console.warn("[puppet] the client renderer is not available; commands fail until it reloads");
}
} else {
console.error("[puppet] action failed:", action, error);
}
respond(socket, {
id,
ok: false,
+142 -3
View File
@@ -14,8 +14,9 @@
*/
import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
import { findByPropsLazy } from "@vencord/types/webpack";
import { findByPropsLazy, findStoreLazy } from "@vencord/types/webpack";
import {
ChannelRTCStore,
ChannelStore,
GuildMemberStore,
GuildStore,
@@ -45,6 +46,63 @@ const ChannelActions = findByPropsLazy("selectVoiceChannel");
/** Discord's media engine actions, located lazily at runtime. */
const MediaEngineActions = findByPropsLazy("setSelfMute");
/** Per-user volume store, located lazily at runtime. */
const UserVolumeStore = findStoreLazy("UserVolumeStore");
/** Discord's per-user volume actions, located lazily at runtime. */
const UserVolumeActions = findByPropsLazy("setUserVolume");
/** Media engine store, for the client's own output volume. */
const MediaEngineStore = findStoreLazy("MediaEngineStore");
function userVolumeOf(userId: string): number | null {
const candidates: any[] = [UserVolumeStore, UserVolumeActions, MediaEngineStore, MediaEngineActions];
for (const candidate of candidates) {
try {
const getter = candidate?.getUserVolume;
if (typeof getter !== "function") continue;
const raw = getter.call(candidate, userId);
const value = typeof raw === "object" && raw !== null ? (raw as any).volume : raw;
const volume = Number(value);
if (Number.isFinite(volume)) return volume;
} catch {
// The lazy proxy throws when the module is absent.
}
}
return null;
}
function userVolumeSetter(): ((userId: string, volume: number) => void) | undefined {
const candidates: any[] = [UserVolumeStore, UserVolumeActions, MediaEngineStore, MediaEngineActions];
for (const candidate of candidates) {
try {
const setter = candidate?.setUserVolume;
if (typeof setter === "function") return setter.bind(candidate);
} catch {
// The lazy proxy throws when the module is absent.
}
}
return undefined;
}
function outputVolume(): number | null {
try {
const store = MediaEngineStore as any;
if (typeof store?.getLocalVolume === "function") {
const volume = Number(store.getLocalVolume());
return Number.isFinite(volume) ? volume : null;
}
} catch {
// Not available in this client build.
}
return null;
}
function compareIds(a: string, b: string): number {
try {
const left = BigInt(a);
@@ -193,7 +251,8 @@ function voiceMember(state: any, guildId?: string) {
selfDeaf: !!state.selfDeaf,
video: !!state.selfVideo,
stream: !!state.selfStream || !!state.stream,
suppress: !!state.suppress
suppress: !!state.suppress,
volume: userVolumeOf(state.userId)
};
}
@@ -373,6 +432,7 @@ export async function handlePuppetAction(action: string, params: Record<string,
guildId: channel?.guild_id ?? guildId ?? null,
channelId: voiceState.channelId,
channelName: channel?.name ?? null,
outputVolume: outputVolume(),
members: Object.values(states).map(state =>
voiceMember(state, (channel?.guild_id as string | undefined) ?? (guildId as string | undefined))
)
@@ -436,6 +496,72 @@ export async function handlePuppetAction(action: string, params: Record<string,
});
}
case PuppetAction.VoiceSpeaking: {
const targetId = UserStore.getCurrentUser()?.id;
assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in");
const voiceState =
VoiceStateStore.getVoiceStateForUser(targetId) ?? VoiceStateStore.getVoiceState(null, targetId);
if (!voiceState?.channelId) return ok({ channelId: null, userIds: [] });
const userIds = ChannelRTCStore.getSpeakingParticipants(voiceState.channelId)
.filter(participant => participant.speaking)
.map(participant => participant.user?.id ?? participant.id)
.filter(Boolean);
return ok({ channelId: voiceState.channelId, userIds });
}
case PuppetAction.UserVolume: {
const { userIds } = params;
assert(
Array.isArray(userIds) && userIds.length >= 1 && userIds.length <= 50,
PuppetErrorCode.InvalidParam,
"userIds must be an array of 1 to 50 user IDs"
);
assert(
(userIds as string[]).every(id => isSnowflake(id)),
PuppetErrorCode.InvalidSnowflake,
"every user ID must be a snowflake"
);
return ok({
outputVolume: outputVolume(),
users: (userIds as string[]).map(id => ({ id, volume: userVolumeOf(id) }))
});
}
case PuppetAction.UserSetVolume: {
const { userId, volume } = params;
assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake");
assert(
typeof volume === "number" && Number.isFinite(volume) && volume >= 0 && volume <= 200,
PuppetErrorCode.InvalidParam,
"volume must be a number from 0 to 200"
);
const setUserVolume = userVolumeSetter();
assert(
typeof setUserVolume === "function",
PuppetErrorCode.NotSupported,
"per-user volume is not available in this client build"
);
enforceOutboundRateLimit();
try {
setUserVolume(userId, volume);
} catch (error) {
throw new ActionError(
PuppetErrorCode.VoiceFailed,
`setting the user volume failed: ${String(error)}`
);
}
return ok({ userId, volume, outputVolume: outputVolume() });
}
case PuppetAction.VoiceOverview: {
const { guildId } = params;
assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake");
@@ -489,6 +615,18 @@ export async function handlePuppetAction(action: string, params: Record<string,
}
}
// Discord persists self-deafen across sessions. A deafened
// client receives no call audio, which breaks the monitor and
// the transcript, so joining clears it.
const setSelfDeaf = (MediaEngineActions as any)?.setSelfDeaf;
if (typeof setSelfDeaf === "function") {
try {
setSelfDeaf(false);
} catch {
// Best effort: the join still works.
}
}
try {
await selectVoiceChannel(channelId);
} catch (error) {
@@ -791,7 +929,8 @@ export async function handlePuppetAction(action: string, params: Record<string,
} catch (error) {
if (error instanceof ActionError) return fail(error.code, error.message);
const message = error instanceof Error ? error.message : String(error);
console.error("[puppet] action failed:", action, error);
return fail(PuppetErrorCode.Internal, "action failed");
return fail(PuppetErrorCode.Internal, `action failed: ${message}`);
}
}
+11 -3
View File
@@ -217,11 +217,19 @@ export function initPuppetEvents() {
dispatcher.subscribe("SPEAKING", (event: any) => {
try {
if (!event?.userId) return;
const userId = event?.userId ?? event?.user_id;
if (!userId) return;
// Discord sends a number bitfield (1 = microphone). A missing
// flag is treated as a start pulse: some dispatcher payloads
// carry no flag, and the UI decays on its own after silence.
const raw = event.speaking ?? event.speakingFlag;
const speaking = raw === undefined ? true : typeof raw === "boolean" ? raw : Number(raw) !== 0;
VesktopNative.puppet.emit({
type: "VOICE_SPEAKING",
userId: event.userId,
speaking: event.speaking !== false,
userId,
speaking,
channelId: event.channelId ?? event.channel_id ?? null
});
} catch (error) {
+6
View File
@@ -22,7 +22,10 @@ export const enum PuppetAction {
VoiceLeave = "voice.leave",
VoiceMove = "voice.move",
VoiceUsers = "voice.users",
VoiceSpeaking = "voice.speaking",
UserStatus = "user.status",
UserVolume = "user.volume",
UserSetVolume = "user.setVolume",
VoiceDevices = "voice.devices",
VoiceSetDevices = "voice.setDevices",
UserSearch = "user.search",
@@ -44,7 +47,10 @@ export const PuppetActions: readonly PuppetAction[] = [
PuppetAction.VoiceLeave,
PuppetAction.VoiceMove,
PuppetAction.VoiceUsers,
PuppetAction.VoiceSpeaking,
PuppetAction.UserStatus,
PuppetAction.UserVolume,
PuppetAction.UserSetVolume,
PuppetAction.VoiceDevices,
PuppetAction.VoiceSetDevices,
PuppetAction.UserSearch,