fix: VoiceDictation CSP, GhostClient speaking, audio subsystem and Krisp crash fixes
This commit is contained in:
+47
-86
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user