Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls
This commit is contained in:
+26
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user