vesktop-stereo-mic

True stereo Discord voice on macOS/Linux via Vesktop — a Vencord userplugin.
Log | Files | Refs | README | LICENSE

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:
ALICENSE | 21+++++++++++++++++++++
AREADME.md | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconsole-snippet.js | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
AstereoMic/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."); + }, +});