commit 6e6cfc9fc042d509e46a6f6fb8982a61a508dca0
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 25 May 2026 02:35:01 -0500
StereoMic: Vencord userplugin for true stereo Discord voice on Vesktop (macOS/Linux)
SDP munge of local+remote Opus fmtp (stereo=1 / sprop-stereo=1 / maxaveragebitrate) +
2-channel unprocessed capture. Listeners need no setup. Includes a console-snippet for
quick testing without building.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat:
| A | LICENSE | | | 21 | +++++++++++++++++++++ |
| A | README.md | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | console-snippet.js | | | 51 | +++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | stereoMic/index.ts | | | 96 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
4 files changed, 246 insertions(+), 0 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 ganten
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,78 @@
+# StereoMic — true stereo mic for Discord on macOS/Linux (Vesktop)
+
+Discord downmixes your microphone/input to **mono**. The official desktop app does voice
+in a native module that's signing-locked on macOS, so the Windows "stereo mic" patchers
+don't work here. But **Vesktop** (and web Discord) do voice in Chromium WebRTC, where the
+fix is pure JavaScript.
+
+`StereoMic` is a tiny Vencord userplugin that makes your voice transmit in **true stereo**
+at high bitrate. **Listeners need to do nothing** — their client decodes the stereo Opus
+automatically.
+
+## How it works
+
+1. **SDP munge on BOTH descriptions.** It injects `stereo=1; sprop-stereo=1;
+ maxaveragebitrate=320000` into the Opus `a=fmtp` line of the local offer **and the
+ remote answer**. The remote one is the essential half: Chromium's Opus encoder only
+ sends 2 channels if the *receiver* signals stereo, and Discord's SFU answer advertises
+ mono — so you must rewrite the answer too. (Munging only the offer = still mono.)
+2. **2-channel unprocessed capture.** It requests `channelCount: 2` and disables
+ `echoCancellation` / `noiseSuppression` / `autoGainControl` so the stereo image
+ survives to the encoder.
+
+You still need to feed a genuinely **stereo** source into your input device (e.g. a
+hard-panned mix, a stereo virtual device like Loopback/BlackHole 2ch). Mono in = mono out.
+
+## Install (Vesktop)
+
+Vesktop has no built-in userplugin folder, so build Vencord with the plugin and point
+Vesktop at the build.
+
+```bash
+# 1. build Vencord with the plugin
+git clone https://github.com/Vendicated/Vencord
+mkdir -p Vencord/src/userplugins/stereoMic
+cp stereoMic/index.ts Vencord/src/userplugins/stereoMic/index.ts
+cd Vencord && pnpm install && pnpm build
+```
+
+Then load the build in Vesktop, either:
+
+**A) Swap the files (works when launching Vesktop normally):**
+- Quit Vesktop.
+- Copy `Vencord/dist/vencordDesktop*` over
+ `~/Library/Application Support/vesktop/sessionData/vencordFiles/` (macOS path; Linux:
+ `~/.config/vesktop/sessionData/vencordFiles/`).
+- In `…/vesktop/settings/settings.json`, set `"autoUpdate": false` so Vesktop doesn't
+ re-download stock Vencord over your build.
+- Relaunch.
+
+**B) Env var (cleaner, no auto-update conflict):** launch Vesktop with
+`VENCORD_FILES_DIR=/abs/path/to/Vencord/dist`. (For a Dock launch on macOS, set it via a
+LaunchAgent or a wrapper script.)
+
+Finally: **User Settings → Plugins → enable `StereoMic`.** Rejoin voice — it auto-applies
+to every connection.
+
+## Quick test without building
+
+`console-snippet.js` is the same logic as a one-shot you can paste into Vesktop's DevTools
+console (`Cmd/Ctrl+Opt+I`). Paste it, then leave & rejoin voice. Good for verifying before
+you commit to a build.
+
+## Verify
+
+Feed a hard-panned left→right source in and have a friend (or a second account) confirm the
+audio pans fully L↔R. Console will log `[StereoMic] munged setLocalDescription` and
+`[StereoMic] munged setRemoteDescription`.
+
+## Caveats
+
+- Client modification — technically against Discord's ToS; use at your own risk.
+- Verified on macOS Vesktop 1.6.x / Vencord (main). The SDP shape can change; if it stops
+ working, check that the Opus payload is still `opus/48000/2` and that both munge lines fire.
+- Rebuild to update Vencord (auto-update is off while using the file-swap method).
+
+## License
+
+MIT
diff --git a/console-snippet.js b/console-snippet.js
@@ -0,0 +1,51 @@
+// StereoMic — quick-test version. Paste into Vesktop/web-Discord DevTools console
+// (Cmd/Ctrl+Opt+I), then LEAVE and REJOIN voice. Feed a stereo source into your input.
+// This is the same logic as the plugin (stereoMic/index.ts), for testing before you build.
+(() => {
+ if (window.__stereoMic) { console.log("[StereoMic] already active (reload to re-apply)"); return; }
+ window.__stereoMic = true;
+ const BITRATE = 320000; // Opus avg bitrate. Max 510000.
+
+ function munge(sdp, tag) {
+ if (!sdp || !/opus/i.test(sdp)) return sdp;
+ const m = sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
+ if (!m) return sdp;
+ const pt = m[1], lines = sdp.split(/\r?\n/);
+ let touched = false;
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith(`a=fmtp:${pt} `)) {
+ let p = lines[i].slice(`a=fmtp:${pt} `.length);
+ const set = (k, v) => { p = new RegExp(`${k}=[^;]*`).test(p)
+ ? p.replace(new RegExp(`${k}=[^;]*`), `${k}=${v}`) : p + `;${k}=${v}`; };
+ set("stereo", 1); set("sprop-stereo", 1); set("maxaveragebitrate", BITRATE);
+ lines[i] = `a=fmtp:${pt} ${p}`; touched = true;
+ }
+ }
+ if (touched) console.log("[StereoMic] munged " + tag + " (pt " + pt + ")");
+ return lines.join("\r\n");
+ }
+
+ for (const name of ["setLocalDescription", "setRemoteDescription"]) {
+ const orig = RTCPeerConnection.prototype[name];
+ RTCPeerConnection.prototype[name] = function (desc, ...r) {
+ try {
+ if (desc && desc.sdp) {
+ const sdp = munge(desc.sdp, name);
+ desc = (typeof desc.toJSON === "function") ? { type: desc.type, sdp } : Object.assign({}, desc, { sdp });
+ }
+ } catch (e) { console.warn("[StereoMic] munge err", e); }
+ return orig.call(this, desc, ...r);
+ };
+ }
+
+ const md = navigator.mediaDevices, gum = md.getUserMedia.bind(md);
+ md.getUserMedia = function (c) {
+ if (c && c.audio) {
+ const a = (typeof c.audio === "object") ? Object.assign({}, c.audio) : {};
+ a.channelCount = 2; a.echoCancellation = false; a.noiseSuppression = false; a.autoGainControl = false;
+ c = Object.assign({}, c, { audio: a });
+ }
+ return gum(c);
+ };
+ console.log("[StereoMic] console patch active — leave & rejoin voice.");
+})();
diff --git a/stereoMic/index.ts b/stereoMic/index.ts
@@ -0,0 +1,96 @@
+/*
+ * StereoMic — transmit your Discord voice input in TRUE STEREO (Vesktop / web Discord).
+ *
+ * Discord downmixes the mic to mono. On the web/Vesktop client (Chromium WebRTC) the
+ * fix is pure SDP: advertise Opus stereo on BOTH the local offer and the remote answer
+ * (the encoder only sends 2ch if the *receiver* signals stereo — Discord's SFU answer
+ * says mono, so munging the answer is the essential half), plus capture 2 unprocessed
+ * channels. Built/verified on macOS Vesktop. No native patching, no re-signing.
+ *
+ * Drop this folder into Vencord's src/userplugins/ and build from source.
+ */
+import definePlugin from "@utils/types";
+
+const BITRATE = 320000; // Opus avg bitrate (music-grade). Max 510000.
+
+function mungeSdp(sdp: string, tag: string): string {
+ if (!sdp || !/opus/i.test(sdp)) return sdp;
+ const rtpmap = sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
+ if (!rtpmap) return sdp;
+ const pt = rtpmap[1];
+ const lines = sdp.split(/\r?\n/);
+ let touched = false;
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith(`a=fmtp:${pt} `)) {
+ let p = lines[i].slice(`a=fmtp:${pt} `.length);
+ const set = (k: string, v: number) => {
+ const re = new RegExp(`${k}=[^;]*`);
+ p = re.test(p) ? p.replace(re, `${k}=${v}`) : `${p};${k}=${v}`;
+ };
+ set("stereo", 1);
+ set("sprop-stereo", 1);
+ set("maxaveragebitrate", BITRATE);
+ lines[i] = `a=fmtp:${pt} ${p}`;
+ touched = true;
+ }
+ }
+ if (touched) console.log(`[StereoMic] munged ${tag} (pt ${pt})`);
+ return lines.join("\r\n");
+}
+
+let origSetLocal: typeof RTCPeerConnection.prototype.setLocalDescription | undefined;
+let origSetRemote: typeof RTCPeerConnection.prototype.setRemoteDescription | undefined;
+let origGetUserMedia: typeof navigator.mediaDevices.getUserMedia | undefined;
+
+export default definePlugin({
+ name: "StereoMic",
+ description: "Send your mic/virtual input to a voice channel in true stereo at high bitrate (Opus SDP munge of local+remote descriptions + 2-channel unprocessed capture). For Vesktop / web Discord on macOS & Linux.",
+ authors: [{ name: "ganten", id: 0n }],
+
+ start() {
+ const PC = RTCPeerConnection.prototype;
+ origSetLocal = PC.setLocalDescription;
+ origSetRemote = PC.setRemoteDescription;
+
+ const wrap = (orig: any, tag: string) =>
+ function (this: RTCPeerConnection, desc?: any, ...rest: any[]) {
+ try {
+ if (desc && desc.sdp) {
+ const sdp = mungeSdp(desc.sdp, tag);
+ desc = typeof desc.toJSON === "function"
+ ? { type: desc.type, sdp }
+ : Object.assign({}, desc, { sdp });
+ }
+ } catch (e) {
+ console.warn("[StereoMic] munge error", e);
+ }
+ return orig.call(this, desc, ...rest);
+ };
+
+ PC.setLocalDescription = wrap(origSetLocal, "setLocalDescription");
+ PC.setRemoteDescription = wrap(origSetRemote, "setRemoteDescription");
+
+ const md = navigator.mediaDevices;
+ origGetUserMedia = md.getUserMedia.bind(md);
+ md.getUserMedia = function (constraints?: MediaStreamConstraints) {
+ if (constraints && constraints.audio) {
+ const a = typeof constraints.audio === "object" ? { ...constraints.audio } : {};
+ (a as any).channelCount = 2;
+ (a as any).echoCancellation = false;
+ (a as any).noiseSuppression = false;
+ (a as any).autoGainControl = false;
+ constraints = { ...constraints, audio: a };
+ }
+ return origGetUserMedia!(constraints);
+ };
+
+ console.log("[StereoMic] active — (re)join voice to apply. Feed a stereo source into your input device.");
+ },
+
+ stop() {
+ if (origSetLocal) RTCPeerConnection.prototype.setLocalDescription = origSetLocal;
+ if (origSetRemote) RTCPeerConnection.prototype.setRemoteDescription = origSetRemote;
+ if (origGetUserMedia) navigator.mediaDevices.getUserMedia = origGetUserMedia;
+ console.log("[StereoMic] stopped.");
+ },
+});