Skip to content

Instantly share code, notes, and snippets.

@brianredbeard
Last active February 26, 2026 07:26
Show Gist options
  • Select an option

  • Save brianredbeard/c927f12be5ea48a331ee4ae075688a46 to your computer and use it in GitHub Desktop.

Select an option

Save brianredbeard/c927f12be5ea48a331ee4ae075688a46 to your computer and use it in GitHub Desktop.
// ==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