Last active
February 26, 2026 07:26
-
-
Save brianredbeard/c927f12be5ea48a331ee4ae075688a46 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Rambly WebRTC Audio Fix | |
| // @namespace rambly-fix | |
| // @version 1.0 | |
| // @description Fixes permanent audio muting caused by replaceTrack(null) bug in distance-based optimization | |
| // @match https://rambly.app/* | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| // | |
| // BUG: Rambly's multiplayer module calls replaceTrack(null) on audio senders | |
| // when peers are > 320px apart. The restore logic uses: | |
| // | |
| // getSenders().find(s => s.track && s.track.kind === 'audio') | |
| // | |
| // But after replaceTrack(null), sender.track is null, so the find() never | |
| // matches. Audio is permanently killed for that peer — even after walking | |
| // closer, the track is never restored. | |
| // | |
| // FIX 1: Block replaceTrack(null) on audio senders. Spatial audio gain | |
| // already reduces volume to 0 at distance > 300px. | |
| // FIX 2: Detect inbound connections where the remote peer has the same bug | |
| // (receiving near-zero audio packets despite active data channel). | |
| // Force reconnection so the remote peer creates a fresh connection | |
| // with a live audio track. | |
| // FIX 3: Clean up stalled connections stuck in signaling (asymmetric | |
| // announce race condition). | |
| // | |
| (function() { | |
| 'use strict'; | |
| if (window.__ramblyFixInstalled) return; | |
| window.__ramblyFixInstalled = true; | |
| const LOG = '[RamblyFix]'; | |
| // Thresholds for detecting broken inbound audio | |
| const MONITOR_INTERVAL_MS = 15000; | |
| const MIN_AGE_BEFORE_CHECK_MS = 90000; | |
| const MIN_EXPECTED_PACKETS = 1000; | |
| const CONCEALMENT_THRESHOLD = 0.90; | |
| const STALLED_SIGNALING_MS = 30000; | |
| // ========================================================================= | |
| // FIX 1: Prevent replaceTrack(null) on audio senders | |
| // | |
| // This runs on OUR client. It prevents us from permanently muting our | |
| // outbound audio to remote peers. The spatial audio gain (which fades to | |
| // 0 between 170-300px) already makes us inaudible at distance — the | |
| // replaceTrack(null) at 320px is a bandwidth optimization that's buggy. | |
| // | |
| // By keeping the audio track alive, the existing restore logic in D() | |
| // continues to work because getSenders().find() can still match the sender. | |
| // ========================================================================= | |
| const origReplaceTrack = RTCRtpSender.prototype.replaceTrack; | |
| RTCRtpSender.prototype.replaceTrack = function(newTrack) { | |
| if (newTrack === null && this.track && this.track.kind === 'audio') { | |
| console.debug(LOG, 'Blocked replaceTrack(null) on audio sender'); | |
| return Promise.resolve(); | |
| } | |
| return origReplaceTrack.call(this, newTrack); | |
| }; | |
| // ========================================================================= | |
| // FIX 2: Track all RTCPeerConnection instances | |
| // | |
| // Wrap the constructor so we can monitor connection health and force | |
| // reconnection when remote peers have broken audio. | |
| // ========================================================================= | |
| const pcMap = new Map(); | |
| let nextId = 0; | |
| const OrigPC = window.RTCPeerConnection; | |
| window.RTCPeerConnection = function(config, constraints) { | |
| const pc = new OrigPC(config, constraints); | |
| const id = ++nextId; | |
| pcMap.set(pc, { | |
| id, | |
| createdAt: Date.now(), | |
| closed: false, | |
| reconnectAttempted: false, | |
| remoteIP: null | |
| }); | |
| pc.addEventListener('connectionstatechange', () => { | |
| const info = pcMap.get(pc); | |
| if (!info) return; | |
| console.debug(LOG, 'PC#' + id, pc.connectionState); | |
| if (pc.connectionState === 'closed' || pc.connectionState === 'failed') { | |
| info.closed = true; | |
| } | |
| }); | |
| return pc; | |
| }; | |
| // Preserve prototype chain so instanceof checks work | |
| window.RTCPeerConnection.prototype = OrigPC.prototype; | |
| window.RTCPeerConnection.prototype.constructor = window.RTCPeerConnection; | |
| // Copy static methods (e.g. generateCertificate) | |
| for (const prop of Object.getOwnPropertyNames(OrigPC)) { | |
| if (prop === 'prototype' || prop === 'length' || prop === 'name') continue; | |
| try { | |
| Object.defineProperty( | |
| window.RTCPeerConnection, | |
| prop, | |
| Object.getOwnPropertyDescriptor(OrigPC, prop) | |
| ); | |
| } catch (e) { /* read-only props */ } | |
| } | |
| // ========================================================================= | |
| // FIX 3: Connection health monitor | |
| // | |
| // Periodically checks all peer connections for the "silent but connected" | |
| // pattern: active data channel (position updates flowing) but near-zero | |
| // inbound audio packets with >90% concealment. | |
| // | |
| // When detected, closes the local PC. The remote peer's ICE agent detects | |
| // the failure within ~15s and triggers ERR_CONNECTION_FAILURE, which the | |
| // Rambly client handles by creating a fresh peer connection (initiator=true). | |
| // The new connection starts with a live audio track. | |
| // ========================================================================= | |
| async function collectStats() { | |
| const results = []; | |
| for (const [pc, info] of pcMap.entries()) { | |
| if (info.closed) continue; | |
| const entry = { | |
| id: info.id, | |
| state: pc.connectionState, | |
| signalingState: pc.signalingState, | |
| age: Math.round((Date.now() - info.createdAt) / 1000), | |
| reconnectAttempted: info.reconnectAttempted | |
| }; | |
| if (pc.connectionState === 'connected') { | |
| try { | |
| const stats = await pc.getStats(); | |
| let inPkts = 0, inBytes = 0, concealed = 0, totalSamples = 0; | |
| let outPkts = 0, outBytes = 0; | |
| let dcOpen = false, dcMsgRecv = 0; | |
| let remoteAddr = ''; | |
| stats.forEach(r => { | |
| if (r.type === 'inbound-rtp' && r.kind === 'audio') { | |
| inPkts = r.packetsReceived || 0; | |
| inBytes = r.bytesReceived || 0; | |
| concealed = r.concealedSamples || 0; | |
| totalSamples = r.totalSamplesReceived || 0; | |
| } | |
| if (r.type === 'outbound-rtp' && r.kind === 'audio') { | |
| outPkts = r.packetsSent || 0; | |
| outBytes = r.bytesSent || 0; | |
| } | |
| if (r.type === 'data-channel' && r.state === 'open') { | |
| dcOpen = true; | |
| dcMsgRecv = r.messagesReceived || 0; | |
| } | |
| if (r.type === 'remote-candidate') { | |
| remoteAddr = (r.address || '?') + ':' + (r.port || '?'); | |
| } | |
| }); | |
| const concealPct = totalSamples > 0 ? concealed / totalSamples : 0; | |
| Object.assign(entry, { | |
| inPkts, | |
| inKB: Math.round(inBytes / 1024), | |
| outPkts, | |
| outKB: Math.round(outBytes / 1024), | |
| conceal: (concealPct * 100).toFixed(1) + '%', | |
| dc: dcOpen ? 'open' : 'no', | |
| dcRecv: dcMsgRecv, | |
| remote: remoteAddr, | |
| healthy: !( | |
| entry.age > MIN_AGE_BEFORE_CHECK_MS / 1000 && | |
| inPkts < MIN_EXPECTED_PACKETS && | |
| concealPct > CONCEALMENT_THRESHOLD && | |
| dcOpen | |
| ) | |
| }); | |
| info.remoteIP = remoteAddr; | |
| } catch (e) { | |
| entry.error = e.message; | |
| } | |
| } else { | |
| entry.healthy = pc.connectionState === 'new' ? null : false; | |
| } | |
| results.push(entry); | |
| } | |
| return results; | |
| } | |
| async function autoReconnect() { | |
| const status = await collectStats(); | |
| let reconnected = 0; | |
| for (const s of status) { | |
| if (s.healthy === false && !s.reconnectAttempted && s.state === 'connected') { | |
| // Find the PC and close it | |
| for (const [pc, info] of pcMap.entries()) { | |
| if (info.id === s.id && !info.closed && !info.reconnectAttempted) { | |
| console.warn( | |
| LOG, | |
| 'Broken audio on PC#' + info.id, | |
| '(remote: ' + (info.remoteIP || '?') + ',', | |
| 'inbound: ' + s.inPkts + ' pkts,', | |
| 'concealment: ' + s.conceal + ').', | |
| 'Closing to force reconnection...' | |
| ); | |
| info.reconnectAttempted = true; | |
| pc.close(); | |
| info.closed = true; | |
| reconnected++; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| if (reconnected > 0) { | |
| console.log( | |
| LOG, | |
| 'Closed ' + reconnected + ' broken connection(s).', | |
| 'Remote peer(s) should reconnect within ~15 seconds.' | |
| ); | |
| } | |
| return reconnected; | |
| } | |
| async function cleanupStalled() { | |
| let cleaned = 0; | |
| for (const [pc, info] of pcMap.entries()) { | |
| if (info.closed) continue; | |
| const age = Date.now() - info.createdAt; | |
| if ( | |
| age > STALLED_SIGNALING_MS && | |
| pc.signalingState === 'have-local-offer' && | |
| pc.iceConnectionState === 'new' | |
| ) { | |
| console.warn( | |
| LOG, | |
| 'PC#' + info.id + ' stuck in signaling for ' + | |
| Math.round(age / 1000) + 's. Closing stalled connection.' | |
| ); | |
| pc.close(); | |
| info.closed = true; | |
| cleaned++; | |
| } | |
| } | |
| return cleaned; | |
| } | |
| // Prune old closed entries from the map to prevent memory leak | |
| function pruneMap() { | |
| const cutoff = Date.now() - 600000; // 10 minutes | |
| for (const [pc, info] of pcMap.entries()) { | |
| if (info.closed && info.createdAt < cutoff) { | |
| pcMap.delete(pc); | |
| } | |
| } | |
| } | |
| // ========================================================================= | |
| // Console API | |
| // ========================================================================= | |
| window.ramblyFix = { | |
| // Print connection status table | |
| status: async function() { | |
| const status = await collectStats(); | |
| console.table(status); | |
| return status; | |
| }, | |
| // Force reconnect only broken connections | |
| reconnect: async function() { | |
| // Reset reconnect flags so we can retry | |
| for (const [, info] of pcMap.entries()) { | |
| if (info.closed && info.reconnectAttempted) { | |
| info.reconnectAttempted = false; | |
| } | |
| } | |
| return await autoReconnect(); | |
| }, | |
| // Force reconnect ALL connections (nuclear option) | |
| reconnectAll: async function() { | |
| let count = 0; | |
| for (const [pc, info] of pcMap.entries()) { | |
| if (info.closed) continue; | |
| if (pc.connectionState !== 'connected') continue; | |
| console.log(LOG, 'Force closing PC#' + info.id); | |
| pc.close(); | |
| info.closed = true; | |
| count++; | |
| } | |
| console.log(LOG, 'Closed all ' + count + ' connections. Peers will reconnect.'); | |
| return count; | |
| }, | |
| // Clean up stalled signaling connections | |
| cleanup: async function() { | |
| return await cleanupStalled(); | |
| } | |
| }; | |
| // ========================================================================= | |
| // Start monitoring | |
| // ========================================================================= | |
| function startMonitor() { | |
| console.log(LOG, 'Rambly WebRTC Audio Fix v1.0 active'); | |
| console.log(LOG, 'Patches applied:'); | |
| console.log(LOG, ' - replaceTrack(null) blocked on audio senders'); | |
| console.log(LOG, ' - Connection health monitor running every ' + (MONITOR_INTERVAL_MS / 1000) + 's'); | |
| console.log(LOG, 'Console API:'); | |
| console.log(LOG, ' ramblyFix.status() — show all connections'); | |
| console.log(LOG, ' ramblyFix.reconnect() — reconnect broken audio'); | |
| console.log(LOG, ' ramblyFix.reconnectAll() — reconnect everything'); | |
| setInterval(async () => { | |
| await cleanupStalled(); | |
| await autoReconnect(); | |
| pruneMap(); | |
| }, MONITOR_INTERVAL_MS); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', startMonitor); | |
| } else { | |
| startMonitor(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment