fix: VoiceDictation CSP, GhostClient speaking, audio subsystem and Krisp crash fixes

This commit is contained in:
Dev
2026-05-27 17:05:07 +02:00
parent 39f1998fd1
commit 5fdf10b86a
6 changed files with 125 additions and 215 deletions
+47 -86
View File
@@ -1,16 +1,3 @@
/**
* ghost-server.js — Nightcord Ghost Client
* Architecture "always-on" + optimisé performance :
* - Cache ffmpeg/device au démarrage (plus de execSync pendant l'audio)
* - Buffer pré-alloué (plus de Buffer.concat à chaque frame)
* - Priorité process élevée (ABOVE_NORMAL)
*
* FIX streaming infini :
* - /stream-start répond IMMÉDIATEMENT (202) puis résout en arrière-plan
* - /stream-status permet à l'UI de poller l'état de la résolution
* - Plus de blocage HTTP pendant yt-dlp (30s) ou le démarrage ffmpeg
*/
import http from "http";
import https from "https";
import { Client } from "discord.js-selfbot-v13";
@@ -39,7 +26,6 @@ let _fluentFfmpeg = null;
import("@dank074/discord-video-stream").then(m => {
DVS = m;
console.log("[GhostServer] DVS OK");
// Pré-charger fluent-ffmpeg dès que DVS est prêt — évite le dynamic import au moment du stream
import("fluent-ffmpeg").then(m2 => {
_fluentFfmpeg = m2.default ?? m2;
console.log("[GhostServer] fluent-ffmpeg pré-chargé OK");
@@ -50,7 +36,6 @@ try { OpusScript = require("opusscript"); console.log("[GhostServer] opusscript
catch (e) { console.error("[GhostServer] opusscript introuvable: " + e.message); }
try {
// Utilise os.setPriority (natif Node.js) au lieu de wmic (obsolète/lent)
os.setPriority(process.pid, os.constants.priority.PRIORITY_ABOVE_NORMAL);
console.log("[GhostServer] Priorité processeur augmentée ✓");
} catch (e) {
@@ -60,9 +45,9 @@ try {
function findFfmpeg() {
if (_ffmpegCache !== null) return _ffmpegCache;
const candidates = [
path.join(__dirname, "..", "..", "ffmpeg.exe"), // Racine (production)
path.join(__dirname, "..", "ffmpeg.exe"), // Resources (dev/dist)
path.join(__dirname, "ffmpeg.exe") // Local
path.join(__dirname, "..", "..", "ffmpeg.exe"),
path.join(__dirname, "..", "ffmpeg.exe"),
path.join(__dirname, "ffmpeg.exe")
];
for (const c of candidates) { if (fs.existsSync(c)) { _ffmpegCache = c; return c; } }
try { let p = require("ffmpeg-static"); p = p?.default ?? p; if (p && fs.existsSync(p)) { _ffmpegCache = p; return p; } } catch { }
@@ -127,7 +112,6 @@ async function findYtDlp() {
} catch { }
}
// Si on arrive ici, il est vraiment absent. On tente le téléchargement.
if (_ytdlpDownloadPromise) return _ytdlpDownloadPromise;
const target = path.join(__dirname, "yt-dlp.exe");
@@ -149,21 +133,17 @@ async function findYtDlp() {
return _ytdlpDownloadPromise;
}
// Cache des URLs résolues — évite de relancer yt-dlp pour la même URL
// Limité à 100 entrées pour éviter les fuites mémoire
const _resolvedUrlCache = new Map();
const MAX_CACHE_SIZE = 100;
async function resolveVideoUrl(url) {
if (/\.(mp4|mkv|webm|m3u8|mov|avi)(\?|$)/i.test(url)) return url;
// Cache : si déjà résolue récemment (< 5 min) on retourne directement
const cached = _resolvedUrlCache.get(url);
if (cached && (Date.now() - cached.ts) < 5 * 60 * 1000) return cached.resolved;
const ytdlp = await findYtDlp();
if (!ytdlp) throw new Error("yt-dlp manquant (échec du téléchargement)");
if (typeof ytdlp !== "string") throw new Error("yt-dlp en cours de téléchargement, réessaie dans 10 secondes...");
return new Promise((resolve, reject) => {
// Timeout étendu à 30s pour les connexions lentes
const proc = spawn(ytdlp, [
"-g",
"--no-playlist",
@@ -201,12 +181,9 @@ setImmediate(() => {
});
const sessions = new Map();
const audioPipelines = new Map(); // Legacy: userId -> { udpTarget }
const sharedAudios = new Map(); // micDevice -> { proc, encoder, users: Map<userId, udpConn> }
// FIX streaming infini : état de chaque stream en cours de démarrage
// Permet à l'UI de poller /stream-status sans bloquer la requête /stream-start
const streamJobs = new Map(); // userId → { state: "resolving"|"starting"|"active"|"error", error?: string }
const audioPipelines = new Map();
const sharedAudios = new Map();
const streamJobs = new Map();
async function preconnectGhost({ userId, token, micLabel, micDevice }) {
micLabel = micLabel || micDevice || "default";
@@ -267,7 +244,6 @@ async function joinVoice(userId, guildId, channelId, micLabel, micDevice) {
const s = sessions.get(userId);
if (!s) return { ok: false, error: "Session introuvable" };
// Si déjà connecté, on force un leave propre d'abord pour reset l'audio
if (s.udpConn) {
await leaveVoice(userId);
await new Promise(r => setTimeout(r, 500));
@@ -286,7 +262,6 @@ async function joinVoice(userId, guildId, channelId, micLabel, micDevice) {
async function doJoinVoice(session, guildId, channelId) {
stopStream(session);
// Tentative de récupération des noms pour les logs (optionnel)
const guild = session.client.guilds.cache.get(guildId);
const channel = guild?.channels.cache.get(channelId);
if (channel) console.log("[GhostServer] Rejoindre: " + channel.name);
@@ -310,12 +285,10 @@ async function doJoinVoice(session, guildId, channelId) {
session.streamer.joinVoice(guildId, channelId, { receiveAudio: true }).then(u => { console.log("[GhostServer] joinVoice resolved! ready=" + u?.ready); return u; }),
new Promise((_, r) => setTimeout(() => r(new Error("Timeout connexion WebRTC")), 15000))
]);
break; // Succès
break;
} catch (e) {
console.error(`[GhostServer] ❌ joinVoice tentative ${attempts} a echoue:`, e.message);
if (attempts >= 2) throw e;
// NE PAS appeler leaveVoice() — ça efface les listeners VOICE_SERVER_UPDATE
// Juste stopper la VoiceConnection WebSocket si elle existe
try { session.streamer.voiceConnection?.stop(); } catch { }
try { session.streamer._voiceConnection = undefined; } catch { }
try { session.streamer._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE"); } catch { }
@@ -328,24 +301,35 @@ async function doJoinVoice(session, guildId, channelId) {
try { udpConn.setPacketizer("H264"); } catch { }
console.log("[GhostServer] Voice connecte (avec reception audio) ✓");
// Activer l'audio ET setSpeaking seulement quand WebRTC est vraiment "connected"
// sendAudioFrame retourne silencieusement si udpConn.ready === false
// Sur les PCs lents/connexions lentes, WebRTC peut prendre 2-10s a devenir "connected"
function activateAudio() {
console.log("[GhostServer] ✅ WebRTC connected — audio + speaking actifs pour " + session.userId);
try { udpConn.mediaConnection?.setSpeaking(true); } catch { }
// On s'enregistre dans le pipeline audio (partagé ou non)
audioPipelines.set(session.userId, { udpTarget: udpConn });
startPermanentAudio(session, udpConn);
function setSpeaking(udpTarget, session, enabled) {
try {
if (udpTarget?.mediaConnection?.setSpeaking) {
udpTarget.mediaConnection.setSpeaking(enabled);
} else if (session?.streamer?.voiceConnection?.setSpeaking) {
session.streamer.voiceConnection.setSpeaking(enabled);
} else if (session?.client?.voice?.setSpeaking) {
session.client.voice.setSpeaking(enabled);
} else {
console.warn("[GhostServer] Aucun moyen d'appeler setSpeaking pour " + session?.userId);
}
} catch { }
}
function activateAudio() {
console.log("[GhostServer] ✅ WebRTC connected — audio + speaking actifs pour " + session.userId);
setSpeaking(udpConn, session, true);
audioPipelines.set(session.userId, { udpTarget: udpConn });
const audioOk = startPermanentAudio(session, udpConn);
if (!audioOk) {
console.error("[GhostServer] Échec du démarrage du pipeline audio pour " + session.userId);
}
}
// DVS v5/v6 compatible: polling sur udpConn.ready
if (udpConn.ready) {
activateAudio();
} else {
let activated = false;
// Essayer onStateChange (DVS v6)
try {
const webRtcConn = udpConn?.webRtcConn?.webRtcConn ?? udpConn?.webRtcConn;
if (webRtcConn && typeof webRtcConn.onStateChange === 'function') {
@@ -355,7 +339,6 @@ async function doJoinVoice(session, guildId, channelId) {
});
}
} catch {}
// Polling universel
let polls = 0;
const poll = setInterval(() => {
polls++;
@@ -416,9 +399,12 @@ async function joinVoiceSilent(userId, guildId, channelId, micLabel, micDevice,
try { udpConn.setPacketizer("H264"); } catch { }
function activateAudio() {
try { udpConn.mediaConnection?.setSpeaking(true); } catch { }
setSpeaking(udpConn, s, true);
audioPipelines.set(userId, { udpTarget: udpConn });
startPermanentAudio(s, udpConn);
const audioOk = startPermanentAudio(s, udpConn);
if (!audioOk) {
console.error("[GhostServer] Échec du démarrage du pipeline audio pour " + userId);
}
}
if (udpConn.ready) {
@@ -451,17 +437,15 @@ async function leaveVoice(userId) {
const s = sessions.get(userId);
if (!s) return;
// On coupe l'audio AVANT de quitter WebRTC pour éviter des frames corrompues
stopMic(s);
stopStream(s);
if (s.udpConn) {
try { s.udpConn.mediaConnection?.setSpeaking(false); } catch { }
setSpeaking(s.udpConn, s, false);
try { s.streamer.leaveVoice(); } catch { }
s.udpConn = null;
}
// Petit délai de sécurité pour que le matériel audio soit relâché proprement par ffmpeg
await new Promise(r => setTimeout(r, 200));
console.log("[GhostServer] " + userId + " quitté le salon");
@@ -488,7 +472,6 @@ function resolveDevice(session) {
const devs = listDshowDevices(ffmpeg);
let device = (session.micLabel && session.micLabel !== "default") ? session.micLabel : null;
// Correspondance intelligente (casse, espaces, accents)
if (device && devs.length > 0 && !devs.includes(device)) {
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const targetClean = clean(device);
@@ -504,17 +487,15 @@ function startPermanentAudio(session, initialUdpConn) {
const { ffmpeg, device } = resolveDevice(session);
if (!ffmpeg || !OpusScript || !device) {
console.warn("[GhostServer] Pipeline impossible: ffmpeg=" + !!ffmpeg + " opus=" + !!OpusScript + " device=" + device);
return;
return false;
}
// Gestion du Pipeline Partagé par micro
// Vérifier à nouveau après résolution du device (évite race condition)
if (sharedAudios.has(device)) {
const shared = sharedAudios.get(device);
if (shared) {
shared.users.set(session.userId, initialUdpConn);
console.log(`[GhostServer] Micro ${device} déjà actif, ajout de l'utilisateur ${session.userId} au flux partagé`);
return;
return true;
}
}
@@ -541,7 +522,6 @@ function startPermanentAudio(session, initialUdpConn) {
proc.on("exit", code => {
sharedAudios.delete(device);
// Reset l'encodeur Opus pour libérer la mémoire native
try { encoder.delete(); } catch { }
if (code !== 0 && code !== null) console.log("[GhostServer] ffmpeg micro " + device + " exit: " + code);
});
@@ -578,12 +558,10 @@ function startPermanentAudio(session, initialUdpConn) {
dataLen -= PCM_BYTES;
const opusFrame = encoder.encode(frame, FRAME_SIZE);
// Broadcast aux utilisateurs actifs
for (const [uid, target] of shared.users) {
if (target?.ready) {
try { target.sendAudioFrame(opusFrame, FRAME_DUR); } catch { }
} else {
// Update target si joinVoice a fini entre temps
const pipe = audioPipelines.get(uid);
if (pipe?.udpTarget) shared.users.set(uid, pipe.udpTarget);
}
@@ -592,16 +570,15 @@ function startPermanentAudio(session, initialUdpConn) {
});
console.log("[GhostServer] Pipeline audio partagé actif ✓");
return true;
}
function stopMic(session) {
audioPipelines.delete(session.userId);
// Retrait de l'utilisateur du flux partagé
for (const [device, shared] of sharedAudios) {
if (shared.users.has(session.userId)) {
shared.users.delete(session.userId);
console.log(`[GhostServer] Retrait de ${session.userId} du micro ${device}`);
// Si plus personne n'écoute ce micro, on coupe ffmpeg
if (shared.users.size === 0) {
try { shared.proc.kill("SIGKILL"); } catch { }
sharedAudios.delete(device);
@@ -628,7 +605,7 @@ function stopAll(session) {
stopMic(session);
stopStream(session);
if (session.udpConn) {
try { session.udpConn.mediaConnection?.setSpeaking(false); } catch { }
setSpeaking(session.udpConn, session, false);
try { session.streamer.leaveVoice(); } catch { }
session.udpConn = null;
}
@@ -747,7 +724,7 @@ http.createServer(async (req, res) => {
const pipe = audioPipelines.get(userId);
if (s?.udpConn && pipe) {
pipe.udpTarget = s.udpConn;
try { s.udpConn.mediaConnection?.setSpeaking(true); } catch { }
setSpeaking(s.udpConn, s, true);
}
}
console.log(`[GhostServer] Audio sync ${joined.length}/${ids.length}`);
@@ -760,24 +737,14 @@ http.createServer(async (req, res) => {
if (req.url === "/disconnect") { await leaveVoice(body.userId); send(res, 200, { ok: true }); return; }
if (req.url === "/destroy") { await destroyGhost(body.userId); send(res, 200, { ok: true }); return; }
// FIX STREAMING INFINI :
// Avant ce fix, /stream-start attendait la résolution yt-dlp (jusqu'à 30s) DANS la requête HTTP.
// Pendant ce temps, le serveur HTTP ne répondait plus à RIEN (Node.js single-thread),
// donc l'UI Discord voyait toutes ses requêtes bloquer → "chargement infini" partout.
//
// Solution : répondre IMMÉDIATEMENT avec { ok: true, resolving: true }
// et traiter la résolution + le démarrage ffmpeg en arrière-plan.
// L'UI peut poller /stream-status pour suivre l'état.
if (req.url === "/stream-start") {
const s = sessions.get(body.userId);
if (!s) { send(res, 200, { ok: false, error: "Session introuvable" }); return; }
// Répondre immédiatement — ne pas bloquer le serveur HTTP
const jobId = Date.now().toString();
streamJobs.set(body.userId, { state: "resolving", jobId });
send(res, 200, { ok: true, resolving: true, jobId });
// Traitement asynchrone EN ARRIÈRE-PLAN
setImmediate(async () => {
try {
streamJobs.set(body.userId, { state: "starting", jobId });
@@ -793,14 +760,11 @@ http.createServer(async (req, res) => {
return;
}
// Nouveau endpoint : l'UI polle cet endpoint pour savoir si le stream a démarré
// { state: "resolving"|"starting"|"active"|"error", error?: string }
if (req.url === "/stream-status") {
const s = sessions.get(body.userId);
if (!s) { send(res, 200, { ok: false, error: "Session introuvable" }); return; }
const job = streamJobs.get(body.userId);
if (!job) {
// Pas de job en cours — vérifier si le stream est actif
send(res, 200, { ok: true, state: s.streaming ? "active" : "idle" });
} else {
send(res, 200, { ok: true, ...job });
@@ -826,19 +790,18 @@ http.createServer(async (req, res) => {
"Connection": "keep-alive"
});
// Header WAV pour stream "infini" (Data size = 0xFFFFFFFF)
const wavHeader = Buffer.alloc(44);
wavHeader.write("RIFF", 0);
wavHeader.writeUInt32LE(0xFFFFFFFF, 4);
wavHeader.write("WAVE", 8);
wavHeader.write("fmt ", 12);
wavHeader.writeUInt32LE(16, 16);
wavHeader.writeUInt16LE(1, 20); // PCM
wavHeader.writeUInt16LE(2, 22); // Channels
wavHeader.writeUInt32LE(48000, 24); // Rate
wavHeader.writeUInt32LE(48000 * 2 * 2, 28); // Byte rate
wavHeader.writeUInt16LE(4, 32); // Block align
wavHeader.writeUInt16LE(16, 34); // Bits per sample
wavHeader.writeUInt16LE(1, 20);
wavHeader.writeUInt16LE(2, 22);
wavHeader.writeUInt32LE(48000, 24);
wavHeader.writeUInt32LE(48000 * 2 * 2, 28);
wavHeader.writeUInt16LE(4, 32);
wavHeader.writeUInt16LE(16, 34);
wavHeader.write("data", 36);
wavHeader.writeUInt32LE(0xFFFFFFFF, 40);
@@ -846,7 +809,6 @@ http.createServer(async (req, res) => {
const decoder = new OpusScript(48000, 2, OpusScript.Application.VOIP);
// On s'abonne via le streamer si possible (plus propre sur DVS)
const udp = s.udpConn;
if (udp?.mediaConnection?.on) {
udp.mediaConnection.on("audio", (id, frame) => {
@@ -857,7 +819,6 @@ http.createServer(async (req, res) => {
} catch { }
});
} else {
// Fallback silence pour tester si pas d'audio
const silence = Buffer.alloc(960 * 4, 0);
const int = setInterval(() => { if (!res.writable) clearInterval(int); else res.write(silence); }, 20);
req.on("close", () => clearInterval(int));
+30 -51
View File
@@ -1,9 +1,3 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NativeSettings } from "@main/settings";
import { session } from "electron";
@@ -16,64 +10,57 @@ export const ImageAndCssSrc = [...ImageSrc, ...CssSrc];
export const ImageScriptsAndCssSrc = [...ImageAndCssSrc, "script-src", "worker-src"];
export const CSPSrc = ["style-src", "connect-src", "img-src", "frame-src", "font-src", "media-src", "worker-src"];
// Plugins can whitelist their own domains by importing this object in their native.ts
// script and just adding to it. But generally, you should just edit this file instead
export const CspPolicies: PolicyMap = {
"http://localhost:*": ImageAndCssSrc,
"http://127.0.0.1:*": ImageAndCssSrc,
"localhost:*": ImageAndCssSrc,
"127.0.0.1:*": ImageAndCssSrc,
"*.github.io": ImageAndCssSrc, // GitHub pages, used by most themes
"github.com": ImageAndCssSrc, // GitHub content (stuff uploaded to markdown forms), used by most themes
"raw.githubusercontent.com": ImageAndCssSrc, // GitHub raw, used by some themes
"*.github.io": ImageAndCssSrc,
"github.com": ImageAndCssSrc,
"raw.githubusercontent.com": ImageAndCssSrc,
"*.raw.githubusercontent.com": ImageAndCssSrc,
"github-production-user-asset-6210df.s3.amazonaws.com": CSPSrc, // GitHub video assets
"*.gitlab.io": ImageAndCssSrc, // GitLab pages, used by some themes
"gitlab.com": ImageAndCssSrc, // GitLab raw, used by some themes
"*.codeberg.page": ImageAndCssSrc, // Codeberg pages, used by some themes
"codeberg.org": ImageAndCssSrc, // Codeberg raw, used by some themes
"github-production-user-asset-6210df.s3.amazonaws.com": CSPSrc,
"*.gitlab.io": ImageAndCssSrc,
"gitlab.com": ImageAndCssSrc,
"*.codeberg.page": ImageAndCssSrc,
"codeberg.org": ImageAndCssSrc,
"*.githack.com": ImageAndCssSrc, // githack (namely raw.githack.com), used by some themes
"jsdelivr.net": ImageAndCssSrc, // jsDelivr, used by very few themes
"*.githack.com": ImageAndCssSrc,
"jsdelivr.net": ImageAndCssSrc,
"fonts.googleapis.com": CssSrc, // Google Fonts, used by many themes
"fonts.googleapis.com": CssSrc,
"i.imgur.com": ImageSrc, // Imgur, used by some themes
"i.ibb.co": ImageSrc, // ImgBB, used by some themes
"i.pinimg.com": ImageSrc, // Pinterest, used by some themes
"*.tenor.com": ImageSrc, // Tenor, used by some themes
"files.catbox.moe": ImageAndCssSrc, // Catbox, used by some themes
"i.imgur.com": ImageSrc,
"i.ibb.co": ImageSrc,
"i.pinimg.com": ImageSrc,
"*.tenor.com": ImageSrc,
"files.catbox.moe": ImageAndCssSrc,
"cdn.discordapp.com": ImageAndCssSrc, // Discord CDN, used by Vencord and some themes to load media
"media.discordapp.net": ImageSrc, // Discord media CDN, possible alternative to Discord CDN
"cdn.discordapp.com": ImageAndCssSrc,
"media.discordapp.net": ImageSrc,
// CDNs used for some things by Vencord.
// FIXME: we really should not be using CDNs anymore
"cdnjs.cloudflare.com": ImageScriptsAndCssSrc,
"cdn.jsdelivr.net": ImageScriptsAndCssSrc,
// Function Specific
// Google Speech API - needed for SpeechRecognition (VoiceDictation plugin)
"api.groq.com": ConnectSrc,
"*.speech.googleapis.com": ConnectSrc,
"speech.googleapis.com": ConnectSrc,
"www.google.com": ConnectSrc,
"*.google.com": ConnectSrc,
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // Last.fm API
"translate-pa.googleapis.com": ConnectSrc, // Google Translate API
"*.vencord.dev": ImageSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": ImageSrc, // ReviewDB API
"decor.fieryflames.dev": ConnectSrc, // Decor API
"ugc.decor.fieryflames.dev": ImageSrc, // Decor CDN
"sponsor.ajay.app": ConnectSrc, // Dearrow API
"dearrow-thumb.ajay.app": ImageSrc, // Dearrow Thumbnail CDN
"usrbg.is-hardly.online": ImageSrc, // USRBG API
"icons.duckduckgo.com": ImageSrc, // DuckDuckGo Favicon API (Reverse Image Search)
"api.github.com": ConnectSrc,
"ws.audioscrobbler.com": ConnectSrc,
"translate-pa.googleapis.com": ConnectSrc,
"*.vencord.dev": ImageSrc,
"manti.vendicated.dev": ImageSrc,
"decor.fieryflames.dev": ConnectSrc,
"ugc.decor.fieryflames.dev": ImageSrc,
"sponsor.ajay.app": ConnectSrc,
"dearrow-thumb.ajay.app": ImageSrc,
"usrbg.is-hardly.online": ImageSrc,
"icons.duckduckgo.com": ImageSrc,
// SoundCord Player
"*.sndcdn.com": CSPSrc,
"soundcloud.com": CSPSrc,
"*.soundcloud.com": CSPSrc,
@@ -129,9 +116,6 @@ const patchCsp = (headers: PolicyMap) => {
};
pushDirective("style-src", "'unsafe-inline'");
// we could make unsafe-inline safe by using strict-dynamic with a random nonce on our Vencord loader script https://content-security-policy.com/strict-dynamic/
// HOWEVER, at the time of writing (24 Jan 2025), Discord is INSANE and also uses unsafe-inline
// Once they stop using it, we also should
pushDirective("script-src", "'unsafe-inline'", "'unsafe-eval'");
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
@@ -160,8 +144,6 @@ export function initCsp() {
if (resourceType === "mainFrame" || resourceType === "subFrame")
patchCsp(responseHeaders);
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet") {
const header = findHeader(responseHeaders, "content-type");
if (header)
@@ -172,8 +154,5 @@ export function initCsp() {
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
}
+13 -7
View File
@@ -258,27 +258,33 @@ if (!IS_VANILLA) {
// If we wait for app.whenReady(), Discord's handler is already registered
// and ipcMain.handle() throws "Attempted to register a second handler" → crash.
//
// Strategy: patch ipcMain.handle itself to intercept Discord's registration
// and replace it with our own guarded version immediately.
// Strategy: patch ipcMain.handle itself to catch ALL duplicate registrations
// (not just fullscreen). If a second handler is registered for the same channel,
// we silently ignore it instead of crashing.
{
const _originalHandle = electron.ipcMain.handle.bind(electron.ipcMain);
const FULLSCREEN_CHANNEL = "DISCORD_WINDOW_TOGGLE_FULLSCREEN";
let _fullscreenPatched = false;
// Override ipcMain.handle so when Discord calls .handle(FULLSCREEN, ...)
// we silently drop it and register our own handler instead.
(electron.ipcMain as any).handle = function(channel: string, listener: any) {
if (channel === FULLSCREEN_CHANNEL) {
if (_fullscreenPatched) return; // already registered ours — ignore Discord's
if (_fullscreenPatched) return;
_fullscreenPatched = true;
// Register our handler instead of Discord's
_originalHandle(FULLSCREEN_CHANNEL, (event: electron.IpcMainInvokeEvent) => {
const win = electron.BrowserWindow.fromWebContents(event.sender);
if (win) win.setFullScreen(!win.isFullScreen());
});
return;
}
return _originalHandle(channel, listener);
try {
return _originalHandle(channel, listener);
} catch (e: any) {
if (e?.message?.includes?.("Attempted to register a second handler")) {
console.warn(`[Nightcord] Ignored duplicate IPC handler for '${channel}'`);
return;
}
throw e;
}
};
}
+13 -16
View File
@@ -1,9 +1,3 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
@@ -30,11 +24,16 @@ function resetKrispPipeline() {
// Step 1: switch to Studio (full noise suppression — resets the pipeline)
MediaSettingsStore.setNoiseSuppressionLevel?.("studio");
// Step 2: switch back to Voice Isolation (Krisp) — now correctly initialised
// Step 2: restore user's original setting (don't force 'krisp')
setTimeout(() => {
try {
MediaSettingsStore.setNoiseSuppressionLevel?.("krisp");
console.log("[FixKrisp] Pipeline reset complete — Krisp correctly initialised.");
if (original && original !== "studio") {
MediaSettingsStore.setNoiseSuppressionLevel?.(original);
console.log("[FixKrisp] Pipeline reset complete — restored '" + original + "'.");
} else {
MediaSettingsStore.setNoiseSuppressionLevel?.("krisp");
console.log("[FixKrisp] Pipeline reset complete — Krisp initialised.");
}
} catch { }
}, 400);
} catch { }
@@ -101,11 +100,12 @@ export default definePlugin({
`;
document.head.appendChild(script);
// Auto-reset pipeline when user joins a voice channel
// This replaces the manual Studio → Isolation cycle
// ONLY reset pipeline when user joins a DIFFERENT voice channel
// (avoid disrupting users already in a call on startup/reload)
let lastResetChannel: string | null = null;
const handler = (event: any) => {
if (event?.type === "VOICE_CHANNEL_SELECT" && event.channelId) {
// Small delay to let Discord connect the audio engine first
if (event?.type === "VOICE_CHANNEL_SELECT" && event.channelId && event.channelId !== lastResetChannel) {
lastResetChannel = event.channelId;
setTimeout(() => resetKrispPipeline(), 1200);
}
};
@@ -115,9 +115,6 @@ export default definePlugin({
this._callCleanup = () => FD?.unsubscribe?.("VOICE_CHANNEL_SELECT", handler);
} catch { }
// Also run once on startup for users already in a call (e.g. after a reload)
setTimeout(() => resetKrispPipeline(), 2000);
// Ensure UI displays it
const style = document.createElement("style");
style.textContent = `
+21 -54
View File
@@ -1,18 +1,9 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { definePluginSettings } from "@api/Settings";
import { showApiKeyWarning } from "@utils/apiKeyWarning";
import definePlugin, { OptionType } from "@utils/types";
import { ComponentDispatch, MediaEngineStore, React, showToast, Toasts,useEffect, useRef, useState } from "@webpack/common";
import { ComponentDispatch, React, useEffect, useRef, useState, MediaEngineStore, showToast, Toasts } from "@webpack/common";
import { getGroqKey } from "../nightcordAI/groqManager";
// ── Settings ──────────────────────────────────────────────────────────────────
import { showApiKeyWarning } from "@utils/apiKeyWarning";
const settings = definePluginSettings({
language: {
@@ -38,8 +29,6 @@ const settings = definePluginSettings({
},
});
// ── SVG Icon ──────────────────────────────────────────────────────────────────
const DictationIcon: React.FC<{ recording?: boolean; processing?: boolean; height?: string | number; width?: string | number; className?: string; }> = ({ recording = false, processing = false, height = 20, width = 20, className }) => (
<svg
aria-hidden="true"
@@ -58,8 +47,6 @@ const DictationIcon: React.FC<{ recording?: boolean; processing?: boolean; heigh
</svg>
);
// ── Transcription ─────────────────────────────────────────────────────────────
function insertText(text: string) {
ComponentDispatch.dispatchToLastSubscribed("INSERT_TEXT", {
rawText: text,
@@ -80,7 +67,6 @@ async function transcribe(blob: Blob): Promise<string> {
form.append("file", blob, "audio.webm");
form.append("model", "whisper-large-v3-turbo");
form.append("response_format", "text");
// Prompt pour orienter Whisper vers du français et éviter les hallucinations "Thank you"
form.append("prompt", "Ceci est une dictée vocale en français. Ne pas traduire en anglais. Ne pas générer de texte si il n'y a que du silence.");
if (language) form.append("language", language);
@@ -99,8 +85,6 @@ async function transcribe(blob: Blob): Promise<string> {
return text.trim();
}
// ── Chat Bar Button ───────────────────────────────────────────────────────────
const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
const [recording, setRecording] = useState(false);
const [processing, setProcessing] = useState(false);
@@ -109,6 +93,7 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const activeRef = useRef(false);
const stoppingRef = useRef(false);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -124,35 +109,33 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
setErrorMsg(null);
activeRef.current = true;
// Helper to map Discord input device to real WebAudio device
async function getRealInputDeviceId(discordId: string): Promise<string> {
if (!discordId || discordId === "default") return "default";
try {
const devs = MediaEngineStore.getInputDevices();
const selected = devs[discordId];
if (!selected || !selected.name) return "default";
let webDevs = await navigator.mediaDevices.enumerateDevices();
// trigger permissions if empty labels
if (webDevs.some(d => d.kind === "audioinput" && !d.label)) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
webDevs = await navigator.mediaDevices.enumerateDevices();
}
let match = webDevs.find(d => d.kind === "audioinput" && d.deviceId === discordId);
if (!match) {
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const normSelected = normalize(selected.name);
match = webDevs.find(d => {
if (d.kind !== "audioinput" || !d.label) return false;
const normLabel = normalize(d.label);
return normLabel.includes(normSelected) || normSelected.includes(normLabel);
});
}
if (match) {
console.log(`[VoiceDictation] Mapped Discord device "${selected.name}" to WebAudio deviceId "${match.deviceId}"`);
showToast(`Dictation: Using mic "${match.label || selected.name}"`, Toasts.Type.SUCCESS);
@@ -167,14 +150,12 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
return "default";
}
// Open microphone
let stream: MediaStream;
try {
const discordDeviceId = MediaEngineStore.getInputDeviceId();
const realDeviceId = await getRealInputDeviceId(discordDeviceId);
try {
// Premier essai : avec le deviceId spécifique (fonctionne si permission ok)
stream = await navigator.mediaDevices.getUserMedia({
audio: realDeviceId && realDeviceId !== "default"
? { deviceId: { exact: realDeviceId } }
@@ -182,7 +163,6 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
});
} catch (firstErr: any) {
if (firstErr.name === "NotAllowedError" || firstErr.name === "PermissionDeniedError") {
// Fallback: permission pas encore accordée, demander sans deviceId
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} else {
throw firstErr;
@@ -198,7 +178,6 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
return;
}
// Choose audio format
const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"]
.find(m => MediaRecorder.isTypeSupported(m)) ?? "";
@@ -213,7 +192,6 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
recorder.start();
setRecording(true);
// Flush and transcribe at regular intervals
const chunkMs = (settings.store.chunkSeconds ?? 5) * 1000;
timerRef.current = setInterval(() => flushAndTranscribe(), chunkMs);
}
@@ -221,7 +199,6 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
async function flushAndTranscribe() {
if (!recorderRef.current || recorderRef.current.state !== "recording") return;
// Stop briefly to get a complete blob
recorderRef.current.stop();
await new Promise<void>(resolve => {
@@ -231,45 +208,34 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
const chunks = [...chunksRef.current];
chunksRef.current = [];
if (chunks.length === 0 || !activeRef.current) {
// Restart if still active
if (activeRef.current && streamRef.current) restartRecorder();
if (chunks.length === 0 || !activeRef.current || stoppingRef.current) {
if (activeRef.current && !stoppingRef.current && streamRef.current) restartRecorder();
return;
}
const mimeType = recorderRef.current?.mimeType || "audio/webm";
const blob = new Blob(chunks, { type: mimeType });
// Debug log
console.log("[VoiceDictation] Blob size:", blob.size);
if (blob.size < 500) {
if (activeRef.current) restartRecorder();
return; // Silence / too short
if (activeRef.current && !stoppingRef.current) restartRecorder();
return;
}
setProcessing(true);
try {
const text = await transcribe(blob);
// Debug log
console.log("[VoiceDictation] Transcribed text:", text);
if (text && text.length > 0) {
// Filter common Whisper hallucinations (silence → phantom text)
const t = text.trim();
const isHallucination =
// Simple exact patterns
/^(merci|thanks?|thank you|music|♪|🎵|\.\.\.|\.\s*)+$/i.test(t) ||
// Subtitling / subtitles
/sous[- ]?titr/i.test(t) ||
// Radio-Canada, SRC, etc.
/radio[- ]?canada|société radio/i.test(t) ||
// "Thanks for watching", etc.
/merci .*(regard|écouter|suivi)|thanks? .*watch/i.test(t) ||
// "Transcription by...", "Transcribed by..."
/transcri(ption|t)\s*(par|by)/i.test(t) ||
// Repetitive text (same word/syllable 3+ times)
/^(.{1,15})\1{2,}$/i.test(t.replace(/\s+/g, "")) ||
// Too short and punctuation only
/^[\s.,!?…\-–—]+$/.test(t);
if (!isHallucination) insertText(text + " ");
}
@@ -280,7 +246,7 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
setProcessing(false);
}
if (activeRef.current) restartRecorder();
if (activeRef.current && !stoppingRef.current) restartRecorder();
}
function restartRecorder() {
@@ -304,6 +270,7 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
function stopDictation() {
activeRef.current = false;
stoppingRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -324,7 +291,7 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
function toggle() {
if (recording) {
// Flush immediate transcription on stop
stoppingRef.current = true;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
@@ -338,11 +305,13 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
const tooltip = errorMsg || (processing
const tooltip = errorMsg
? errorMsg
: processing
? "Transcribing..."
: recording
? "Stop dictation"
: "Voice dictation");
: "Voice dictation";
return (
<ChatBarButton tooltip={tooltip} onClick={toggle}>
@@ -351,8 +320,6 @@ const VoiceDictationButton: ChatBarButtonFactory = ({ isMainChat }) => {
);
};
// ── Plugin ────────────────────────────────────────────────────────────────────
export default definePlugin({
name: "VoiceDictation",
enabledByDefault: true,
+1 -1
View File
@@ -46,7 +46,7 @@ if (isLogDirAvailable) {
console.warn('Unable to find log directory');
}
const defaultAudioSubsystem = process.platform === 'win32' ? 'experimental' : 'standard';
const defaultAudioSubsystem = 'standard';
const audioSubsystem = appSettings
? appSettings.getSync('audioSubsystem', defaultAudioSubsystem)
: defaultAudioSubsystem;