chore: dev-stack switches, shared e2e harness, and desktop shell rules
- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine suspend; the reload client otherwise destroys the session under test. - `dev-peer.sh` plus a separate userdata dir runs a second local peer. - `tools/voice-probe.js` samples peer state and RTP counters from a live window, persisting to localStorage so a renderer reload cannot erase it. - e2e helpers for voice pairs, peer-role election, and a TURN relay. - Electron single-instance and dev-client-load decisions move into rules files with colocated specs.
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user