/** * Live voice diagnostic probe - paste into the DevTools console of a running * dev window (Ctrl+Shift+I). Requires a development build, because it reads the * Angular debug API (`window.ng`). * * Sampling starts on paste. Every 5s it records signaling state, per-peer * connection/ICE/data-channel state, inbound + outbound audio RTP counters, the * selected candidate pair, and the peer connection's creation timestamp. * * History lives in localStorage, not in the JS heap, so it survives a renderer * reload and an app restart - both of which happen around a machine suspend. * After any reload, paste the probe again: it resumes the same history, and the * reload itself shows up as an event in the report. * * Usage: * (paste) // loads and starts sampling * __voiceProbe.summary() // compact report, safe to copy into chat * __voiceProbe.stop() * __voiceProbe.reset() // discard stored history before a new run */ (() => { const INTERVAL_MS = 5_000; /** Console heartbeat cadence, in samples, between which only changes are printed. */ const HEARTBEAT_EVERY = 12; const STORAGE_KEY = 'metoyou_voice_probe_v1'; const MAX_SAMPLES = 1_500; const HOST_SELECTORS = [ 'app-rooms-side-panel', 'app-chat-room', 'app-dm-workspace' ]; /** Distinct per renderer load, so a reload is visible in the stored history. */ const LOAD_ID = Math.round(performance.timeOrigin); function loadStoredSamples() { try { const raw = localStorage.getItem(STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveStoredSamples(samples) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(samples.slice(-MAX_SAMPLES))); } catch (error) { console.warn('[voice-probe] could not persist samples', error); } } function findRealtime() { const debugApi = window.ng; if (!debugApi || typeof debugApi.getComponent !== 'function') return null; for (const selector of HOST_SELECTORS) { for (const host of Array.from(document.querySelectorAll(selector))) { let component = null; try { component = debugApi.getComponent(host); } catch { continue; } const realtime = component && component.realtime; if (realtime && realtime.peerManager && realtime.peerManager.activePeerConnections) return realtime; } } return null; } async function readPeer(peerId, peerData) { const connection = peerData.connection; const sample = { createdAt: peerData.createdAt || 0, dataChannel: peerData.dataChannel ? peerData.dataChannel.readyState : 'none', iceState: connection ? connection.iceConnectionState : 'none', inPackets: 0, isInitiator: peerData.isInitiator === true, outPackets: 0, pair: '?', peerId: String(peerId).slice(0, 8), state: connection ? connection.connectionState : 'none' }; if (!connection) return sample; let stats = null; try { stats = await connection.getStats(); } catch { return sample; } const candidates = new Map(); let selectedPair = null; stats.forEach((report) => { if (report.type === 'local-candidate' || report.type === 'remote-candidate') candidates.set(report.id, report.candidateType); if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.selected || report.nominated)) selectedPair = report; const kind = report.kind || report.mediaType; if (report.type === 'inbound-rtp' && kind === 'audio') sample.inPackets += report.packetsReceived || 0; if (report.type === 'outbound-rtp' && kind === 'audio') sample.outPackets += report.packetsSent || 0; }); if (selectedPair) { const localType = candidates.get(selectedPair.localCandidateId) || '?'; const remoteType = candidates.get(selectedPair.remoteCandidateId) || '?'; sample.pair = `${localType}/${remoteType}`; } return sample; } /** * The local capture state. Without this, "zero RTP" is ambiguous: it looks identical * whether the call died or the user simply is not in one any more. */ function readLocalMedia(realtime) { let voice = 'unknown'; let mic = 'unknown'; try { voice = realtime.isVoiceConnected() ? 'in-voice' : 'not-in-voice'; } catch {} try { const stream = realtime.getLocalStream(); mic = stream ? stream.getAudioTracks() .map((track) => `${track.readyState}${track.enabled ? '' : '/disabled'}${track.muted ? '/muted' : ''}`) .join('+') || 'no-audio-tracks' : 'no-stream'; } catch {} return { mic, voice }; } async function takeSample(realtime) { const peers = []; const entries = Array.from(realtime.peerManager.activePeerConnections.entries()); for (const [peerId, peerData] of entries) { peers.push(await readPeer(peerId, peerData)); } let signaling = 'unknown'; try { signaling = realtime.isConnected() ? 'up' : 'DOWN'; } catch {} const local = readLocalMedia(realtime); return { at: Date.now(), load: LOAD_ID, mic: local.mic, peers, signaling, voice: local.voice }; } let timerId = null; let lastFingerprint = ''; function start() { if (timerId !== null) { console.log('[voice-probe] already running'); return; } const realtime = findRealtime(); if (!realtime) { console.error('[voice-probe] no realtime service found. Open a room/voice view on a dev build first.'); return; } const tick = () => { takeSample(realtime) .then((sample) => { const samples = loadStoredSamples(); samples.push(sample); saveStoredSamples(samples); const peerText = sample.peers .map((peer) => `${peer.peerId} ${peer.state} in=${peer.inPackets} out=${peer.outPackets}`) .join(' | '); const fingerprint = `${sample.signaling}|${sample.voice}|${sample.mic}|` + sample.peers.map((peer) => `${peer.peerId}:${peer.state}:${peer.dataChannel}:${peer.createdAt}`).join(','); // Every console line is retained by DevTools for the life of the session, and a // long soak with an open inspector is how the debugger itself ends up using // gigabytes. Print on change, plus a periodic heartbeat. const isHeartbeat = samples.length % HEARTBEAT_EVERY === 1; if (isHeartbeat || fingerprint !== lastFingerprint) { console.log( `[voice-probe] #${samples.length} signaling=${sample.signaling} ${sample.voice} mic=${sample.mic}` + ` ${peerText || '(no peers)'}` ); } lastFingerprint = fingerprint; }) .catch((error) => console.error('[voice-probe] sample failed', error)); }; tick(); timerId = setInterval(tick, INTERVAL_MS); } function stop() { if (timerId !== null) { clearInterval(timerId); timerId = null; } console.log(`[voice-probe] stopped with ${loadStoredSamples().length} samples stored`); } function reset() { stop(); try { localStorage.removeItem(STORAGE_KEY); } catch {} console.log('[voice-probe] history cleared'); } function describeGap(previous, current) { const gapMs = current.at - previous.at; if (gapMs < INTERVAL_MS * 2.5) return null; return `GAP ${Math.round(gapMs / 1000)}s with no samples`; } function peerLines(previous, current) { const lines = []; for (const peer of current.peers) { const before = previous.peers.find((candidate) => candidate.peerId === peer.peerId); if (!before) { lines.push(` + peer ${peer.peerId} appeared (state=${peer.state}, initiator=${peer.isInitiator})`); continue; } if (before.createdAt !== peer.createdAt) { lines.push( ` ! peer ${peer.peerId} REBUILT - connection now created at ${new Date(peer.createdAt).toISOString()}` + ` (was ${new Date(before.createdAt).toISOString()})` ); } if (before.state !== peer.state) lines.push(` ~ peer ${peer.peerId} state ${before.state} -> ${peer.state}`); if (before.dataChannel !== peer.dataChannel) lines.push(` ~ peer ${peer.peerId} data channel ${before.dataChannel} -> ${peer.dataChannel}`); if (before.pair !== peer.pair) lines.push(` ~ peer ${peer.peerId} candidate pair ${before.pair} -> ${peer.pair}`); // Only a peer that is supposed to be carrying voice can "stall"; outside a call // zero RTP is the correct reading, not a fault. if (peer.state === 'connected' && before.state === 'connected' && current.voice === 'in-voice') { const inDelta = peer.inPackets - before.inPackets; const outDelta = peer.outPackets - before.outPackets; if (inDelta <= 0 || outDelta <= 0) lines.push(` x peer ${peer.peerId} audio stalled (in +${inDelta}, out +${outDelta})`); } } for (const peer of previous.peers) { if (!current.peers.some((candidate) => candidate.peerId === peer.peerId)) lines.push(` - peer ${peer.peerId} disappeared`); } return lines; } function describePeers(sample) { if (sample.peers.length === 0) return 'none'; return sample.peers .map((peer) => `${peer.peerId}(${peer.state}, born ${new Date(peer.createdAt).toISOString()})`) .join(', '); } function summary() { const samples = loadStoredSamples(); if (samples.length === 0) return '[voice-probe] no samples stored'; const first = samples[0]; const last = samples[samples.length - 1]; const loads = Array.from(new Set(samples.map((sample) => sample.load))); const lines = [ '=== voice-probe summary ===', `samples: ${samples.length} over ${Math.round((last.at - first.at) / 1000)}s, renderer loads: ${loads.length}`, `voice at start: ${first.voice}, mic ${first.mic}`, `voice at end: ${last.voice}, mic ${last.mic}`, `peers at start: ${describePeers(first)}`, `peers at end: ${describePeers(last)}`, `audio totals end: ${last.peers.map((peer) => `${peer.peerId} in=${peer.inPackets} out=${peer.outPackets}`).join(', ')}`, '--- events ---' ]; const eventCountBefore = lines.length; for (let index = 1; index < samples.length; index++) { const previous = samples[index - 1]; const current = samples[index]; const gap = describeGap(previous, current); const reloaded = previous.load !== current.load; // Counters and peer identity are meaningless across a reload: the whole app restarted. const events = reloaded ? [] : peerLines(previous, current); if (reloaded) events.push(' ! RENDERER RELOADED - the app restarted, so peers below are new by definition'); if (previous.mic !== current.mic) events.unshift(` ~ local mic ${previous.mic} -> ${current.mic}`); if (previous.voice !== current.voice) events.unshift(` ~ voice ${previous.voice} -> ${current.voice}`); if (previous.signaling !== current.signaling) events.unshift(` ~ signaling ${previous.signaling} -> ${current.signaling}`); if (!gap && events.length === 0) continue; lines.push(`#${index + 1} +${Math.round((current.at - first.at) / 1000)}s${gap ? ` ${gap}` : ''}`); lines.push(...events); } if (lines.length === eventCountBefore) lines.push(' (no state changes, no rebuilds, no audio stalls, no reloads)'); const text = lines.join('\n'); console.log(text); return text; } window.__voiceProbe = { reset, start, stop, summary }; const stored = loadStoredSamples(); console.log( `[voice-probe] loaded (load id ${LOAD_ID}). ${stored.length} samples already stored.` + ` Sampling every ${INTERVAL_MS / 1000}s - call __voiceProbe.summary() when done.` ); start(); })();