Refactor backend configuration management and update environment settings
- Replace the `config.py` module with a new `settings.py` to streamline environment variable management, focusing on database, CORS, and TURN settings. - Update references throughout the backend codebase to use the new `settings` module instead of the deprecated `config`. - Modify the `.env.example` file to reflect the new configuration approach, indicating that model provider credentials should be maintained separately. - Enhance the `AssistantConfig` model to clarify the source of runtime connection information, ensuring it is injected from model resources rather than relying on defaults from the environment. - Introduce new user scripts for audio and video management in the Tampermonkey environment, enhancing WebRTC capabilities.
This commit is contained in:
371
scripts/tampermonkey/audio-sender-sine-tester.js
Normal file
371
scripts/tampermonkey/audio-sender-sine-tester.js
Normal file
@@ -0,0 +1,371 @@
|
||||
// ==UserScript==
|
||||
// @name 12345 Audio Sender Sine Tester
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0
|
||||
// @description List current WebRTC audio senders and temporarily send a sine wave through a selected sender.
|
||||
// @author You
|
||||
// @match *://yhy-yw.22sj.cn:*/*
|
||||
// @grant none
|
||||
// @run-at document-start
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const originalAddTrack = RTCPeerConnection.prototype.addTrack;
|
||||
const originalAddTransceiver = RTCPeerConnection.prototype.addTransceiver;
|
||||
const originalSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
|
||||
|
||||
const senderCandidates = new Map();
|
||||
const outboundStatsMemory = new Map();
|
||||
|
||||
let activeTest = null;
|
||||
let lastRenderAt = 0;
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleTimeString([], { hour12: false });
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
if (!id) return 'null';
|
||||
return id.length <= 18 ? id : `${id.slice(0, 8)}...${id.slice(-6)}`;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(`[Audio Sine Tester] ${message}`);
|
||||
const box = document.getElementById('audio-sine-tester-log');
|
||||
if (!box) return;
|
||||
const row = document.createElement('div');
|
||||
row.textContent = `[${nowText()}] ${message}`;
|
||||
box.appendChild(row);
|
||||
while (box.children.length > 80) box.removeChild(box.firstChild);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function ensureSenderCandidate(sender, track, source) {
|
||||
if (!sender) return null;
|
||||
const senderTrack = track || sender.track;
|
||||
if (senderTrack && senderTrack.kind !== 'audio') return null;
|
||||
|
||||
const id = sender.__audioSineTesterId || `sender-${senderCandidates.size + 1}`;
|
||||
sender.__audioSineTesterId = id;
|
||||
|
||||
let candidate = senderCandidates.get(id);
|
||||
if (!candidate) {
|
||||
candidate = {
|
||||
id,
|
||||
sender,
|
||||
track: senderTrack || null,
|
||||
originalTrack: senderTrack || null,
|
||||
sources: new Set(),
|
||||
bytesSent: 0,
|
||||
packetsSent: 0,
|
||||
bytesDelta: 0,
|
||||
packetsDelta: 0,
|
||||
score: 0,
|
||||
};
|
||||
senderCandidates.set(id, candidate);
|
||||
log(`found audio sender ${id} (${source}) track=${senderTrack?.id || 'null'}`);
|
||||
}
|
||||
|
||||
candidate.sender = sender;
|
||||
candidate.track = sender.track || senderTrack || candidate.track;
|
||||
if (sender.track && sender.track.kind === 'audio' && !sender.track.__audioSineTesterTrack) {
|
||||
candidate.originalTrack = sender.track;
|
||||
}
|
||||
candidate.sources.add(source);
|
||||
wrapSender(sender);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function wrapSender(sender) {
|
||||
if (!sender || sender.__audioSineTesterWrapped) return;
|
||||
const originalReplaceTrack = sender.replaceTrack?.bind(sender);
|
||||
if (!originalReplaceTrack) return;
|
||||
|
||||
sender.replaceTrack = async function (track) {
|
||||
const result = await originalReplaceTrack(track);
|
||||
const candidate = ensureSenderCandidate(sender, track, 'replaceTrack');
|
||||
if (candidate) {
|
||||
candidate.track = track || sender.track || candidate.track;
|
||||
if (track && !track.__audioSineTesterTrack) candidate.originalTrack = track;
|
||||
log(`${candidate.id} replaceTrack -> ${track?.id || 'null'}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
sender.__audioSineTesterWrapped = true;
|
||||
}
|
||||
|
||||
async function refreshSenderStats() {
|
||||
for (const candidate of senderCandidates.values()) {
|
||||
const sender = candidate.sender;
|
||||
candidate.track = sender.track || candidate.track;
|
||||
if (!sender.getStats) continue;
|
||||
|
||||
try {
|
||||
const report = await sender.getStats();
|
||||
report.forEach((stat) => {
|
||||
const kind = stat.kind || stat.mediaType;
|
||||
if (stat.type !== 'outbound-rtp' || kind !== 'audio') return;
|
||||
|
||||
const key = `${candidate.id}:${stat.id}`;
|
||||
const prev = outboundStatsMemory.get(key);
|
||||
const bytes = stat.bytesSent || 0;
|
||||
const packets = stat.packetsSent || 0;
|
||||
candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
|
||||
candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
|
||||
candidate.bytesSent = bytes;
|
||||
candidate.packetsSent = packets;
|
||||
outboundStatsMemory.set(key, { bytes, packets });
|
||||
candidate.score = scoreCandidate(candidate);
|
||||
});
|
||||
} catch (error) {
|
||||
candidate.sources.add(`stats-error:${error.message || error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate) {
|
||||
let score = 0;
|
||||
if (candidate.track?.kind === 'audio') score += 1000;
|
||||
if (candidate.track?.readyState === 'live') score += 800;
|
||||
if (candidate.sources.has('pc-addTrack')) score += 1500;
|
||||
if (candidate.bytesSent > 0) score += 600;
|
||||
if (candidate.bytesDelta > 0) score += Math.min(2500, candidate.bytesDelta * 4);
|
||||
if (candidate.packetsDelta > 0) score += Math.min(1000, candidate.packetsDelta * 12);
|
||||
return score;
|
||||
}
|
||||
|
||||
function makeSineTrack(frequency) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new AudioCtx({ sampleRate: 48000 });
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
const dest = context.createMediaStreamDestination();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.value = 0.08;
|
||||
oscillator.connect(gain);
|
||||
gain.connect(dest);
|
||||
oscillator.start();
|
||||
|
||||
const track = dest.stream.getAudioTracks()[0];
|
||||
track.__audioSineTesterTrack = true;
|
||||
track.__audioSineTesterContext = context;
|
||||
track.__audioSineTesterOscillator = oscillator;
|
||||
track.__audioSineTesterGain = gain;
|
||||
return track;
|
||||
}
|
||||
|
||||
function stopTrack(track) {
|
||||
if (!track) return;
|
||||
try { track.__audioSineTesterOscillator?.stop(); } catch {}
|
||||
try { track.stop(); } catch {}
|
||||
try { track.__audioSineTesterContext?.close(); } catch {}
|
||||
}
|
||||
|
||||
async function startSine(candidateId, frequency) {
|
||||
const candidate = senderCandidates.get(candidateId);
|
||||
if (!candidate?.sender) return;
|
||||
|
||||
await stopSine(false);
|
||||
|
||||
const originalTrack = candidate.sender.track || candidate.originalTrack || null;
|
||||
const sineTrack = makeSineTrack(frequency);
|
||||
await candidate.sender.replaceTrack(sineTrack);
|
||||
|
||||
activeTest = {
|
||||
candidateId,
|
||||
sender: candidate.sender,
|
||||
originalTrack,
|
||||
sineTrack,
|
||||
frequency,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
candidate.originalTrack = originalTrack;
|
||||
candidate.track = sineTrack;
|
||||
log(`START ${frequency}Hz on ${candidateId}; original=${originalTrack?.id || 'null'}`);
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
async function stopSine(shouldLog = true) {
|
||||
if (!activeTest) return;
|
||||
const test = activeTest;
|
||||
activeTest = null;
|
||||
|
||||
try {
|
||||
await test.sender.replaceTrack(test.originalTrack || null);
|
||||
if (shouldLog) log(`STOP ${test.frequency}Hz on ${test.candidateId}; restored=${test.originalTrack?.id || 'null'}`);
|
||||
} catch (error) {
|
||||
log(`restore failed on ${test.candidateId}: ${error.message || error}`);
|
||||
} finally {
|
||||
stopTrack(test.sineTrack);
|
||||
}
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
function dump() {
|
||||
return {
|
||||
activeTest: activeTest ? {
|
||||
candidateId: activeTest.candidateId,
|
||||
frequency: activeTest.frequency,
|
||||
sineTrackId: activeTest.sineTrack.id,
|
||||
originalTrackId: activeTest.originalTrack?.id || null,
|
||||
seconds: Math.round((Date.now() - activeTest.startedAt) / 1000),
|
||||
} : null,
|
||||
senders: Array.from(senderCandidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
active: activeTest?.candidateId === candidate.id,
|
||||
trackId: candidate.sender.track?.id || candidate.track?.id || null,
|
||||
trackLabel: candidate.sender.track?.label || candidate.track?.label || null,
|
||||
originalTrackId: candidate.originalTrack?.id || null,
|
||||
trackEnabled: candidate.sender.track?.enabled ?? null,
|
||||
trackMuted: candidate.sender.track?.muted ?? null,
|
||||
trackReadyState: candidate.sender.track?.readyState || null,
|
||||
bytesSent: candidate.bytesSent,
|
||||
packetsSent: candidate.packetsSent,
|
||||
bytesDelta: candidate.bytesDelta,
|
||||
packetsDelta: candidate.packetsDelta,
|
||||
score: Math.round(candidate.score),
|
||||
sources: Array.from(candidate.sources),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
if (!document.body) return;
|
||||
let panel = document.getElementById('audio-sine-tester-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'audio-sine-tester-panel';
|
||||
panel.style.cssText = [
|
||||
'position:fixed',
|
||||
'top:20px',
|
||||
'right:20px',
|
||||
'width:500px',
|
||||
'max-height:76vh',
|
||||
'z-index:999999',
|
||||
'box-sizing:border-box',
|
||||
'padding:12px',
|
||||
'border-radius:8px',
|
||||
'background:rgba(3,7,18,.96)',
|
||||
'color:#e5e7eb',
|
||||
'font:12px ui-monospace,Menlo,monospace',
|
||||
'box-shadow:0 12px 32px rgba(0,0,0,.35)',
|
||||
'overflow:auto',
|
||||
].join(';');
|
||||
panel.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="color:#fcd34d;font:13px system-ui,sans-serif;">Audio Sender Sine Tester</strong>
|
||||
<button id="audio-sine-copy" style="border:0;border-radius:5px;padding:5px 8px;background:#2563eb;color:white;cursor:pointer;">copy dump</button>
|
||||
</div>
|
||||
<div id="audio-sine-summary" style="margin-bottom:8px;color:#d1d5db;"></div>
|
||||
<div id="audio-sine-list"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button id="audio-sine-stop" style="flex:1;border:0;border-radius:5px;padding:8px;background:#dc2626;color:white;cursor:pointer;">stop and restore</button>
|
||||
</div>
|
||||
<div id="audio-sine-tester-log" style="margin-top:8px;height:90px;overflow:auto;border-top:1px solid #374151;padding-top:6px;color:#a7f3d0;"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
document.getElementById('audio-sine-copy').addEventListener('click', async () => {
|
||||
const text = JSON.stringify(window.__audioSineTesterDump(), null, 2);
|
||||
await navigator.clipboard?.writeText(text).catch(() => {});
|
||||
console.log('[Audio Sine Tester dump]', text);
|
||||
log('dump copied to clipboard and console');
|
||||
});
|
||||
document.getElementById('audio-sine-stop').addEventListener('click', () => stopSine());
|
||||
}
|
||||
|
||||
const data = dump();
|
||||
document.getElementById('audio-sine-summary').textContent =
|
||||
`senders=${data.senders.length}, active=${data.activeTest ? `${data.activeTest.candidateId} ${data.activeTest.frequency}Hz ${data.activeTest.seconds}s` : 'none'}, time=${nowText()}`;
|
||||
|
||||
document.getElementById('audio-sine-list').innerHTML = data.senders.map((row) => {
|
||||
const pct = Math.min(100, Math.max(row.bytesDelta / 20, row.packetsDelta * 2));
|
||||
const active = row.active;
|
||||
return `
|
||||
<div style="border:1px solid ${active ? '#f59e0b' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;color:${active ? '#fcd34d' : '#cbd5e1'};">
|
||||
<span>${active ? '*' : '-'} ${row.id}</span>
|
||||
<span>score=${row.score}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
|
||||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#60a5fa,#fcd34d);"></div>
|
||||
</div>
|
||||
<div style="color:#cbd5e1;line-height:1.45;word-break:break-all;">
|
||||
track=${shortId(row.trackId)} label=${row.trackLabel || 'null'} muted=${row.trackMuted} bytesDelta=${row.bytesDelta} packetsDelta=${row.packetsDelta}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button data-sine="${row.id}:440" style="border:0;border-radius:5px;padding:6px 8px;background:#059669;color:white;cursor:pointer;">440Hz</button>
|
||||
<button data-sine="${row.id}:880" style="border:0;border-radius:5px;padding:6px 8px;background:#7c3aed;color:white;cursor:pointer;">880Hz</button>
|
||||
</div>
|
||||
<div style="color:#94a3b8;margin-top:5px;word-break:break-all;">${row.sources.join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
panel.querySelectorAll('[data-sine]').forEach((button) => {
|
||||
button.onclick = () => {
|
||||
const [id, hz] = button.dataset.sine.split(':');
|
||||
startSine(id, Number(hz));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.addTrack = function (track, ...streams) {
|
||||
const sender = originalAddTrack.call(this, track, ...streams);
|
||||
if (track?.kind === 'audio') ensureSenderCandidate(sender, track, 'pc-addTrack');
|
||||
return sender;
|
||||
};
|
||||
|
||||
if (originalAddTransceiver) {
|
||||
RTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
|
||||
const transceiver = originalAddTransceiver.call(this, trackOrKind, init);
|
||||
const kind = trackOrKind instanceof MediaStreamTrack ? trackOrKind.kind : trackOrKind;
|
||||
if (kind === 'audio') {
|
||||
const track = trackOrKind instanceof MediaStreamTrack ? trackOrKind : transceiver.sender?.track;
|
||||
ensureSenderCandidate(transceiver.sender, track, 'pc-addTransceiver');
|
||||
}
|
||||
return transceiver;
|
||||
};
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.setRemoteDescription = function () {
|
||||
const result = originalSetRemoteDescription.apply(this, arguments);
|
||||
Promise.resolve(result).then(() => {
|
||||
try {
|
||||
for (const sender of this.getSenders?.() || []) {
|
||||
ensureSenderCandidate(sender, sender.track, 'sender-after-srd');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Audio Sine Tester] failed to scan senders', error);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
window.__audioSineTesterDump = dump;
|
||||
window.__audioSineTesterStop = stopSine;
|
||||
|
||||
setInterval(refreshSenderStats, 250);
|
||||
setInterval(() => {
|
||||
if (Date.now() - lastRenderAt < 500) return;
|
||||
lastRenderAt = Date.now();
|
||||
renderPanel();
|
||||
}, 250);
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (activeTest) stopSine(false);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderPanel, { once: true });
|
||||
} else {
|
||||
renderPanel();
|
||||
}
|
||||
})();
|
||||
652
scripts/tampermonkey/audio-track-probe.js
Normal file
652
scripts/tampermonkey/audio-track-probe.js
Normal file
@@ -0,0 +1,652 @@
|
||||
// ==UserScript==
|
||||
// @name 12345 Audio Track Probe
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0
|
||||
// @description Probe page/WebRTC audio input/output tracks and show active RTP stats.
|
||||
// @author You
|
||||
// @match *://yhy-yw.22sj.cn:*/*
|
||||
// @grant none
|
||||
// @run-at document-start
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const originalAddEventListener = RTCPeerConnection.prototype.addEventListener;
|
||||
const originalAddTrack = RTCPeerConnection.prototype.addTrack;
|
||||
const originalAddTransceiver = RTCPeerConnection.prototype.addTransceiver;
|
||||
const originalSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
|
||||
const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
|
||||
|
||||
const candidates = new Map();
|
||||
const senderCandidates = new Map();
|
||||
const receivers = new Set();
|
||||
const senders = new Set();
|
||||
const statMemory = new Map();
|
||||
const outboundStatMemory = new Map();
|
||||
|
||||
let selectedTrackId = null;
|
||||
let selectedSenderId = null;
|
||||
let lastPanelRenderAt = 0;
|
||||
let senderAudioContext = null;
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleTimeString([], { hour12: false });
|
||||
}
|
||||
|
||||
function isLiveAudioTrack(track) {
|
||||
return track?.kind === 'audio' && track.readyState === 'live';
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
if (!id) return 'none';
|
||||
if (id.length <= 18) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-6)}`;
|
||||
}
|
||||
|
||||
function speechLevel(level) {
|
||||
if (!Number.isFinite(level) || level < 0.0015) return 0;
|
||||
return level;
|
||||
}
|
||||
|
||||
function volumePercent(level) {
|
||||
return Math.round(Math.min(100, speechLevel(level) * 2500));
|
||||
}
|
||||
|
||||
function volumeDb(level) {
|
||||
const activeLevel = speechLevel(level);
|
||||
if (!activeLevel) return '-inf';
|
||||
return `${(20 * Math.log10(activeLevel)).toFixed(1)}dB`;
|
||||
}
|
||||
|
||||
function volumeText(level) {
|
||||
return `vol=${volumePercent(level)}% ${volumeDb(level)}`;
|
||||
}
|
||||
|
||||
function createAudioContext() {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
return AudioContextCtor ? new AudioContextCtor() : null;
|
||||
}
|
||||
|
||||
function analyserLevel(analyser, data) {
|
||||
if (!analyser || !data) return 0;
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (const value of data) {
|
||||
const centered = (value - 128) / 128;
|
||||
sum += centered * centered;
|
||||
}
|
||||
return Math.sqrt(sum / data.length);
|
||||
}
|
||||
|
||||
async function ensureSenderMeter(candidate) {
|
||||
const track = candidate?.track;
|
||||
if (!isLiveAudioTrack(track)) {
|
||||
detachSenderMeter(candidate);
|
||||
return false;
|
||||
}
|
||||
if (candidate.meterTrack === track && candidate.analyser) return true;
|
||||
|
||||
detachSenderMeter(candidate);
|
||||
if (!senderAudioContext) {
|
||||
senderAudioContext = createAudioContext();
|
||||
if (!senderAudioContext) {
|
||||
candidate.sources.add('meter-error:no-audio-context');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (senderAudioContext.state === 'suspended') {
|
||||
await senderAudioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = new MediaStream([track]);
|
||||
candidate.meterSource = senderAudioContext.createMediaStreamSource(stream);
|
||||
candidate.analyser = senderAudioContext.createAnalyser();
|
||||
candidate.analyser.fftSize = 512;
|
||||
candidate.analyser.smoothingTimeConstant = 0.55;
|
||||
candidate.meterData = new Uint8Array(candidate.analyser.fftSize);
|
||||
candidate.meterSource.connect(candidate.analyser);
|
||||
candidate.meterTrack = track;
|
||||
return true;
|
||||
} catch (error) {
|
||||
candidate.sources.add(`meter-error:${error.message || error}`);
|
||||
detachSenderMeter(candidate);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function detachSenderMeter(candidate) {
|
||||
if (!candidate) return;
|
||||
try {
|
||||
candidate.meterSource?.disconnect();
|
||||
} catch {}
|
||||
candidate.meterSource = null;
|
||||
candidate.analyser = null;
|
||||
candidate.meterData = null;
|
||||
candidate.meterTrack = null;
|
||||
}
|
||||
|
||||
function refreshSenderMeterLevel(candidate) {
|
||||
if (!candidate) return;
|
||||
candidate.audioLevel = analyserLevel(candidate.analyser, candidate.meterData);
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(`[Audio Track Probe] ${message}`);
|
||||
const box = document.getElementById('audio-track-probe-log');
|
||||
if (!box) return;
|
||||
const row = document.createElement('div');
|
||||
row.textContent = `[${nowText()}] ${message}`;
|
||||
box.appendChild(row);
|
||||
while (box.children.length > 80) box.removeChild(box.firstChild);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function ensureCandidate(track, source) {
|
||||
if (!isLiveAudioTrack(track)) return null;
|
||||
let candidate = candidates.get(track.id);
|
||||
if (!candidate) {
|
||||
candidate = {
|
||||
track,
|
||||
receiver: null,
|
||||
sources: new Set(),
|
||||
elements: new Set(),
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
bytesReceived: 0,
|
||||
packetsReceived: 0,
|
||||
bytesDelta: 0,
|
||||
packetsDelta: 0,
|
||||
audioLevel: 0,
|
||||
energyLevel: 0,
|
||||
score: 0,
|
||||
};
|
||||
candidates.set(track.id, candidate);
|
||||
log(`found audio track ${track.id} (${source})`);
|
||||
}
|
||||
candidate.track = track;
|
||||
candidate.sources.add(source);
|
||||
candidate.lastSeenAt = Date.now();
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function ensureSenderCandidate(sender, track, source) {
|
||||
if (!sender) return null;
|
||||
const senderTrack = track || sender.track;
|
||||
if (senderTrack && senderTrack.kind !== 'audio') return null;
|
||||
|
||||
const id = sender.__audioTrackProbeId || `sender-${senderCandidates.size + 1}`;
|
||||
sender.__audioTrackProbeId = id;
|
||||
|
||||
let candidate = senderCandidates.get(id);
|
||||
if (!candidate) {
|
||||
candidate = {
|
||||
id,
|
||||
sender,
|
||||
track: senderTrack || null,
|
||||
sources: new Set(),
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
bytesSent: 0,
|
||||
packetsSent: 0,
|
||||
bytesDelta: 0,
|
||||
packetsDelta: 0,
|
||||
audioLevel: 0,
|
||||
analyser: null,
|
||||
meterData: null,
|
||||
meterSource: null,
|
||||
meterTrack: null,
|
||||
score: 0,
|
||||
};
|
||||
senderCandidates.set(id, candidate);
|
||||
log(`found audio sender ${id} (${source}) track=${senderTrack?.id || 'null'}`);
|
||||
}
|
||||
|
||||
candidate.sender = sender;
|
||||
candidate.track = senderTrack || sender.track || candidate.track;
|
||||
candidate.sources.add(source);
|
||||
candidate.lastSeenAt = Date.now();
|
||||
ensureSenderMeter(candidate);
|
||||
senders.add(sender);
|
||||
wrapSender(sender);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function wrapSender(sender) {
|
||||
if (!sender || sender.__audioTrackProbeWrapped) return;
|
||||
const originalReplaceTrack = sender.replaceTrack?.bind(sender);
|
||||
if (!originalReplaceTrack) return;
|
||||
|
||||
sender.replaceTrack = async function (track) {
|
||||
const result = await originalReplaceTrack(track);
|
||||
if (!track || track.kind === 'audio') {
|
||||
const candidate = ensureSenderCandidate(sender, track, 'replaceTrack');
|
||||
if (candidate) {
|
||||
candidate.track = track;
|
||||
ensureSenderMeter(candidate);
|
||||
log(`sender ${candidate.id} replaceTrack -> ${track?.id || 'null'}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
sender.__audioTrackProbeWrapped = true;
|
||||
}
|
||||
|
||||
function rememberReceiver(receiver, source) {
|
||||
if (!receiver?.track || !isLiveAudioTrack(receiver.track)) return;
|
||||
receivers.add(receiver);
|
||||
const candidate = ensureCandidate(receiver.track, source);
|
||||
if (candidate) candidate.receiver = receiver;
|
||||
}
|
||||
|
||||
function rememberStream(stream, source, element) {
|
||||
if (!stream || !(stream instanceof MediaStream)) return;
|
||||
for (const track of stream.getAudioTracks()) {
|
||||
const candidate = ensureCandidate(track, source);
|
||||
if (candidate && element) candidate.elements.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
function scanMediaElements() {
|
||||
document.querySelectorAll('audio, video').forEach((element, index) => {
|
||||
rememberStream(element.srcObject, `element-${index}:${element.tagName.toLowerCase()}`, element);
|
||||
});
|
||||
}
|
||||
|
||||
function levelFromStat(stat, candidate) {
|
||||
let level = typeof stat.audioLevel === 'number' ? stat.audioLevel : 0;
|
||||
if (typeof stat.totalAudioEnergy !== 'number' || typeof stat.totalSamplesDuration !== 'number') {
|
||||
return level;
|
||||
}
|
||||
|
||||
const prev = statMemory.get(stat.id);
|
||||
statMemory.set(stat.id, {
|
||||
bytes: stat.bytesReceived || 0,
|
||||
packets: stat.packetsReceived || 0,
|
||||
energy: stat.totalAudioEnergy,
|
||||
duration: stat.totalSamplesDuration,
|
||||
});
|
||||
|
||||
if (prev) {
|
||||
const energyDelta = stat.totalAudioEnergy - prev.energy;
|
||||
const durationDelta = stat.totalSamplesDuration - prev.duration;
|
||||
if (energyDelta > 0 && durationDelta > 0) {
|
||||
level = Math.max(level, Math.sqrt(energyDelta / durationDelta));
|
||||
}
|
||||
}
|
||||
|
||||
candidate.energyLevel = level;
|
||||
return level;
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
for (const receiver of Array.from(receivers)) {
|
||||
if (!receiver.track || receiver.track.readyState !== 'live') {
|
||||
receivers.delete(receiver);
|
||||
continue;
|
||||
}
|
||||
const candidate = ensureCandidate(receiver.track, 'receiver-stats');
|
||||
if (!candidate || !receiver.getStats) continue;
|
||||
candidate.receiver = receiver;
|
||||
|
||||
try {
|
||||
const report = await receiver.getStats();
|
||||
report.forEach((stat) => {
|
||||
const kind = stat.kind || stat.mediaType;
|
||||
if (stat.type !== 'inbound-rtp' || kind !== 'audio') return;
|
||||
|
||||
const prev = statMemory.get(stat.id);
|
||||
const bytes = stat.bytesReceived || 0;
|
||||
const packets = stat.packetsReceived || 0;
|
||||
candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
|
||||
candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
|
||||
candidate.bytesReceived = bytes;
|
||||
candidate.packetsReceived = packets;
|
||||
candidate.audioLevel = levelFromStat(stat, candidate);
|
||||
});
|
||||
} catch (error) {
|
||||
candidate.sources.add(`stats-error:${error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
chooseActiveTrack();
|
||||
await refreshSenderStats();
|
||||
chooseActiveSender();
|
||||
}
|
||||
|
||||
async function refreshSenderStats() {
|
||||
for (const sender of Array.from(senders)) {
|
||||
if (!sender) {
|
||||
senders.delete(sender);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = ensureSenderCandidate(sender, sender.track, 'sender-stats');
|
||||
if (!candidate || !sender.getStats) continue;
|
||||
await ensureSenderMeter(candidate);
|
||||
refreshSenderMeterLevel(candidate);
|
||||
|
||||
try {
|
||||
const report = await sender.getStats();
|
||||
report.forEach((stat) => {
|
||||
const kind = stat.kind || stat.mediaType;
|
||||
if (stat.type !== 'outbound-rtp' || kind !== 'audio') return;
|
||||
|
||||
const prev = outboundStatMemory.get(stat.id);
|
||||
const bytes = stat.bytesSent || 0;
|
||||
const packets = stat.packetsSent || 0;
|
||||
candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
|
||||
candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
|
||||
candidate.bytesSent = bytes;
|
||||
candidate.packetsSent = packets;
|
||||
if (!candidate.analyser && typeof stat.audioLevel === 'number') {
|
||||
candidate.audioLevel = stat.audioLevel;
|
||||
}
|
||||
outboundStatMemory.set(stat.id, { bytes, packets });
|
||||
});
|
||||
} catch (error) {
|
||||
candidate.sources.add(`sender-stats-error:${error.message || error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate) {
|
||||
if (!candidate || !isLiveAudioTrack(candidate.track)) return -1;
|
||||
let score = 0;
|
||||
if (!candidate.track.muted) score += 1000;
|
||||
if (candidate.receiver) score += 300;
|
||||
if (candidate.bytesDelta > 0) score += 4000 + Math.min(1500, candidate.bytesDelta);
|
||||
if (candidate.packetsDelta > 0) score += 2000 + Math.min(800, candidate.packetsDelta * 8);
|
||||
score += Math.min(1200, candidate.audioLevel * 12000);
|
||||
if (candidate.elements.size > 0) score += 100;
|
||||
return score;
|
||||
}
|
||||
|
||||
function chooseActiveTrack() {
|
||||
let best = null;
|
||||
for (const candidate of candidates.values()) {
|
||||
candidate.score = scoreCandidate(candidate);
|
||||
if (!best || candidate.score > best.score) best = candidate;
|
||||
}
|
||||
if (!best || best.score < 0) return;
|
||||
if (best.track.id !== selectedTrackId) {
|
||||
selectedTrackId = best.track.id;
|
||||
log(`active audio looks like ${best.track.id}, score=${Math.round(best.score)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scoreSenderCandidate(candidate) {
|
||||
if (!candidate) return -1;
|
||||
let score = 0;
|
||||
if (candidate.track?.readyState === 'live') score += 1000;
|
||||
if (candidate.track && !candidate.track.muted) score += 400;
|
||||
if (candidate.bytesSent > 0) score += 300;
|
||||
if (candidate.bytesDelta > 0) score += 4500 + Math.min(1800, candidate.bytesDelta);
|
||||
if (candidate.packetsDelta > 0) score += 1800 + Math.min(900, candidate.packetsDelta * 10);
|
||||
score += Math.min(1000, candidate.audioLevel * 10000);
|
||||
return score;
|
||||
}
|
||||
|
||||
function chooseActiveSender() {
|
||||
let best = null;
|
||||
for (const candidate of senderCandidates.values()) {
|
||||
candidate.score = scoreSenderCandidate(candidate);
|
||||
if (!best || candidate.score > best.score) best = candidate;
|
||||
}
|
||||
if (!best || best.score < 0) return;
|
||||
if (best.id !== selectedSenderId) {
|
||||
selectedSenderId = best.id;
|
||||
log(`active output sender looks like ${best.id}, track=${best.track?.id || 'null'}, score=${Math.round(best.score)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dumpInputs() {
|
||||
return Array.from(candidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((candidate) => ({
|
||||
selected: candidate.track.id === selectedTrackId,
|
||||
id: candidate.track.id,
|
||||
label: candidate.track.label,
|
||||
enabled: candidate.track.enabled,
|
||||
muted: candidate.track.muted,
|
||||
readyState: candidate.track.readyState,
|
||||
hasReceiver: Boolean(candidate.receiver),
|
||||
bytesReceived: candidate.bytesReceived,
|
||||
packetsReceived: candidate.packetsReceived,
|
||||
bytesDelta: candidate.bytesDelta,
|
||||
packetsDelta: candidate.packetsDelta,
|
||||
audioLevel: candidate.audioLevel,
|
||||
volumePercent: volumePercent(candidate.audioLevel),
|
||||
volumeDb: volumeDb(candidate.audioLevel),
|
||||
score: Math.round(candidate.score),
|
||||
sources: Array.from(candidate.sources),
|
||||
elements: Array.from(candidate.elements).map((element) => ({
|
||||
tag: element.tagName,
|
||||
paused: element.paused,
|
||||
muted: element.muted,
|
||||
volume: element.volume,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function dumpOutputs() {
|
||||
return Array.from(senderCandidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((candidate) => ({
|
||||
selected: candidate.id === selectedSenderId,
|
||||
id: candidate.id,
|
||||
trackId: candidate.track?.id || null,
|
||||
trackLabel: candidate.track?.label || null,
|
||||
trackEnabled: candidate.track?.enabled ?? null,
|
||||
trackMuted: candidate.track?.muted ?? null,
|
||||
trackReadyState: candidate.track?.readyState || null,
|
||||
bytesSent: candidate.bytesSent,
|
||||
packetsSent: candidate.packetsSent,
|
||||
bytesDelta: candidate.bytesDelta,
|
||||
packetsDelta: candidate.packetsDelta,
|
||||
audioLevel: candidate.audioLevel,
|
||||
volumePercent: volumePercent(candidate.audioLevel),
|
||||
volumeDb: volumeDb(candidate.audioLevel),
|
||||
score: Math.round(candidate.score),
|
||||
sources: Array.from(candidate.sources),
|
||||
}));
|
||||
}
|
||||
|
||||
function dump() {
|
||||
return {
|
||||
selectedInputTrackId: selectedTrackId,
|
||||
selectedOutputSenderId: selectedSenderId,
|
||||
inputs: dumpInputs(),
|
||||
outputs: dumpOutputs(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
if (!document.body) return;
|
||||
let panel = document.getElementById('audio-track-probe-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'audio-track-probe-panel';
|
||||
panel.style.cssText = [
|
||||
'position:fixed',
|
||||
'top:20px',
|
||||
'right:20px',
|
||||
'width:460px',
|
||||
'max-height:70vh',
|
||||
'z-index:999999',
|
||||
'box-sizing:border-box',
|
||||
'padding:12px',
|
||||
'border-radius:8px',
|
||||
'background:rgba(3,7,18,.96)',
|
||||
'color:#e5e7eb',
|
||||
'font:12px ui-monospace,Menlo,monospace',
|
||||
'box-shadow:0 12px 32px rgba(0,0,0,.35)',
|
||||
'overflow:auto',
|
||||
].join(';');
|
||||
panel.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="color:#93c5fd;font:13px system-ui,sans-serif;">Audio Track Probe</strong>
|
||||
<button id="audio-track-probe-copy" style="border:0;border-radius:5px;padding:5px 8px;background:#2563eb;color:white;cursor:pointer;">copy dump</button>
|
||||
</div>
|
||||
<div id="audio-track-probe-summary" style="margin-bottom:8px;color:#d1d5db;"></div>
|
||||
<div style="color:#93c5fd;margin:8px 0 4px;">Input receivers</div>
|
||||
<div id="audio-track-probe-list"></div>
|
||||
<div style="color:#c084fc;margin:10px 0 4px;">Output senders</div>
|
||||
<div id="audio-track-probe-output-list"></div>
|
||||
<div id="audio-track-probe-log" style="margin-top:8px;height:90px;overflow:auto;border-top:1px solid #374151;padding-top:6px;color:#a7f3d0;"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
document.getElementById('audio-track-probe-copy').addEventListener('click', async () => {
|
||||
const text = JSON.stringify(window.__audioTrackProbeDump(), null, 2);
|
||||
await navigator.clipboard?.writeText(text).catch(() => {});
|
||||
console.log('[Audio Track Probe dump]', text);
|
||||
log('dump copied to clipboard and console');
|
||||
});
|
||||
}
|
||||
|
||||
const rows = dumpInputs();
|
||||
const outputRows = dumpOutputs();
|
||||
const selected = rows.find((row) => row.selected);
|
||||
const selectedOutput = outputRows.find((row) => row.selected);
|
||||
document.getElementById('audio-track-probe-summary').textContent =
|
||||
`inputs=${rows.length}, selected=${selected ? selected.id : 'none'} | outputs=${outputRows.length}, selected=${selectedOutput ? selectedOutput.id : 'none'}, time=${nowText()}`;
|
||||
|
||||
document.getElementById('audio-track-probe-list').innerHTML = rows.map((row) => {
|
||||
const pct = row.volumePercent;
|
||||
const color = row.selected ? '#fcd34d' : '#9ca3af';
|
||||
return `
|
||||
<div style="border:1px solid ${row.selected ? '#f59e0b' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
|
||||
<span>${row.selected ? '*' : '-'} ${shortId(row.id)}</span>
|
||||
<span>${volumeText(row.audioLevel)} score=${row.score}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
|
||||
<div style="height:100%;width:${pct}%;opacity:${pct > 0 ? 1 : 0.35};background:linear-gradient(90deg,#10b981,#fcd34d);"></div>
|
||||
</div>
|
||||
<div style="color:#cbd5e1;line-height:1.45;">
|
||||
muted=${row.muted} receiver=${row.hasReceiver} bytesDelta=${row.bytesDelta} packetsDelta=${row.packetsDelta} level=${row.audioLevel.toFixed(5)}
|
||||
</div>
|
||||
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('audio-track-probe-output-list').innerHTML = outputRows.map((row) => {
|
||||
const pct = row.volumePercent;
|
||||
const color = row.selected ? '#c084fc' : '#9ca3af';
|
||||
return `
|
||||
<div style="border:1px solid ${row.selected ? '#a855f7' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
|
||||
<span>${row.selected ? '*' : '-'} ${row.id}</span>
|
||||
<span>${volumeText(row.audioLevel)} score=${row.score}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
|
||||
<div style="height:100%;width:${pct}%;opacity:${pct > 0 ? 1 : 0.35};background:linear-gradient(90deg,#60a5fa,#c084fc);"></div>
|
||||
</div>
|
||||
<div style="color:#cbd5e1;line-height:1.45;">
|
||||
track=${shortId(row.trackId)} muted=${row.trackMuted} bytesDelta=${row.bytesDelta} packetsDelta=${row.packetsDelta} level=${Number(row.audioLevel || 0).toFixed(5)}
|
||||
</div>
|
||||
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function wrapTrackListener(listener) {
|
||||
if (!listener || listener.__audioTrackProbeWrapped) return listener;
|
||||
const wrapped = function (event) {
|
||||
rememberReceiver(event?.receiver, 'pc-track');
|
||||
if (typeof listener === 'function') return listener.apply(this, arguments);
|
||||
if (listener?.handleEvent) return listener.handleEvent(event);
|
||||
return undefined;
|
||||
};
|
||||
wrapped.__audioTrackProbeWrapped = true;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.addEventListener = function (type, listener, options) {
|
||||
if (type === 'track') return originalAddEventListener.call(this, type, wrapTrackListener(listener), options);
|
||||
return originalAddEventListener.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
RTCPeerConnection.prototype.addTrack = function (track, ...streams) {
|
||||
const sender = originalAddTrack.call(this, track, ...streams);
|
||||
if (track?.kind === 'audio') ensureSenderCandidate(sender, track, 'pc-addTrack');
|
||||
return sender;
|
||||
};
|
||||
|
||||
if (originalAddTransceiver) {
|
||||
RTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
|
||||
const transceiver = originalAddTransceiver.call(this, trackOrKind, init);
|
||||
const kind = trackOrKind instanceof MediaStreamTrack ? trackOrKind.kind : trackOrKind;
|
||||
if (kind === 'audio') {
|
||||
const track = trackOrKind instanceof MediaStreamTrack ? trackOrKind : transceiver.sender?.track;
|
||||
ensureSenderCandidate(transceiver.sender, track, 'pc-addTransceiver');
|
||||
}
|
||||
return transceiver;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack');
|
||||
Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return descriptor?.get ? descriptor.get.call(this) : this.__audioTrackProbeOnTrack || null;
|
||||
},
|
||||
set(listener) {
|
||||
const wrapped = wrapTrackListener(listener);
|
||||
this.__audioTrackProbeOnTrack = wrapped;
|
||||
if (descriptor?.set) descriptor.set.call(this, wrapped);
|
||||
else this.addEventListener('track', wrapped);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Audio Track Probe] failed to wrap ontrack', error);
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.setRemoteDescription = function () {
|
||||
const result = originalSetRemoteDescription.apply(this, arguments);
|
||||
Promise.resolve(result).then(() => {
|
||||
try {
|
||||
for (const receiver of this.getReceivers?.() || []) rememberReceiver(receiver, 'receiver-after-srd');
|
||||
for (const sender of this.getSenders?.() || []) ensureSenderCandidate(sender, sender.track, 'sender-after-srd');
|
||||
} catch (error) {
|
||||
console.warn('[Audio Track Probe] failed to scan receivers', error);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
if (mediaSrcObjectDescriptor?.set) {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
|
||||
configurable: true,
|
||||
enumerable: mediaSrcObjectDescriptor.enumerable,
|
||||
get() {
|
||||
return mediaSrcObjectDescriptor.get.call(this);
|
||||
},
|
||||
set(stream) {
|
||||
rememberStream(stream, `${this.tagName.toLowerCase()}-srcObject`, this);
|
||||
return mediaSrcObjectDescriptor.set.call(this, stream);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.__audioTrackProbeDump = dump;
|
||||
|
||||
setInterval(scanMediaElements, 500);
|
||||
setInterval(refreshStats, 250);
|
||||
setInterval(() => {
|
||||
if (Date.now() - lastPanelRenderAt < 500) return;
|
||||
lastPanelRenderAt = Date.now();
|
||||
renderPanel();
|
||||
}, 250);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderPanel, { once: true });
|
||||
} else {
|
||||
renderPanel();
|
||||
}
|
||||
})();
|
||||
1329
scripts/tampermonkey/copilot-element2-two-party.js
Normal file
1329
scripts/tampermonkey/copilot-element2-two-party.js
Normal file
File diff suppressed because it is too large
Load Diff
1197
scripts/tampermonkey/copilot.js
Normal file
1197
scripts/tampermonkey/copilot.js
Normal file
File diff suppressed because it is too large
Load Diff
626
scripts/tampermonkey/video-track-probe.js
Normal file
626
scripts/tampermonkey/video-track-probe.js
Normal file
@@ -0,0 +1,626 @@
|
||||
// ==UserScript==
|
||||
// @name 12345 Video Track Probe
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0
|
||||
// @description Probe page/WebRTC video input/output tracks and show active RTP stats.
|
||||
// @author You
|
||||
// @match *://yhy-yw.22sj.cn:*/*
|
||||
// @grant none
|
||||
// @run-at document-start
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const originalGetUserMedia = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices);
|
||||
const originalAddEventListener = RTCPeerConnection.prototype.addEventListener;
|
||||
const originalAddTrack = RTCPeerConnection.prototype.addTrack;
|
||||
const originalAddTransceiver = RTCPeerConnection.prototype.addTransceiver;
|
||||
const originalSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
|
||||
const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
|
||||
|
||||
const candidates = new Map();
|
||||
const senderCandidates = new Map();
|
||||
const receivers = new Set();
|
||||
const senders = new Set();
|
||||
const localCaptureTrackIds = new Set();
|
||||
const inboundStatMemory = new Map();
|
||||
const outboundStatMemory = new Map();
|
||||
|
||||
let selectedTrackId = null;
|
||||
let selectedSenderId = null;
|
||||
let lastPanelRenderAt = 0;
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleTimeString([], { hour12: false });
|
||||
}
|
||||
|
||||
function isLiveVideoTrack(track) {
|
||||
return track?.kind === 'video' && track.readyState === 'live';
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
if (!id) return 'none';
|
||||
if (id.length <= 18) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-6)}`;
|
||||
}
|
||||
|
||||
function trackSettings(track) {
|
||||
try {
|
||||
return track?.getSettings?.() || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(`[Video Track Probe] ${message}`);
|
||||
const box = document.getElementById('video-track-probe-log');
|
||||
if (!box) return;
|
||||
const row = document.createElement('div');
|
||||
row.textContent = `[${nowText()}] ${message}`;
|
||||
box.appendChild(row);
|
||||
while (box.children.length > 80) box.removeChild(box.firstChild);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function ensureCandidate(track, source) {
|
||||
if (!isLiveVideoTrack(track)) return null;
|
||||
let candidate = candidates.get(track.id);
|
||||
if (!candidate) {
|
||||
candidate = {
|
||||
track,
|
||||
receiver: null,
|
||||
sources: new Set(),
|
||||
elements: new Set(),
|
||||
isLocalCapture: localCaptureTrackIds.has(track.id),
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
lastPacketAt: 0,
|
||||
bytesReceived: 0,
|
||||
packetsReceived: 0,
|
||||
bytesDelta: 0,
|
||||
packetsDelta: 0,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
framesDropped: 0,
|
||||
framesDelta: 0,
|
||||
frameWidth: 0,
|
||||
frameHeight: 0,
|
||||
framesPerSecond: 0,
|
||||
score: 0,
|
||||
};
|
||||
candidates.set(track.id, candidate);
|
||||
log(`found video track ${track.id} (${source})`);
|
||||
}
|
||||
candidate.track = track;
|
||||
candidate.sources.add(source);
|
||||
candidate.lastSeenAt = Date.now();
|
||||
candidate.isLocalCapture = localCaptureTrackIds.has(track.id);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function ensureSenderCandidate(sender, track, source) {
|
||||
if (!sender) return null;
|
||||
const senderTrack = track || sender.track;
|
||||
if (senderTrack && senderTrack.kind !== 'video') return null;
|
||||
|
||||
const id = sender.__videoTrackProbeId || `sender-${senderCandidates.size + 1}`;
|
||||
sender.__videoTrackProbeId = id;
|
||||
|
||||
let candidate = senderCandidates.get(id);
|
||||
if (!candidate) {
|
||||
candidate = {
|
||||
id,
|
||||
sender,
|
||||
track: senderTrack || null,
|
||||
sources: new Set(),
|
||||
isLocalCapture: Boolean(senderTrack && localCaptureTrackIds.has(senderTrack.id)),
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
bytesSent: 0,
|
||||
packetsSent: 0,
|
||||
bytesDelta: 0,
|
||||
packetsDelta: 0,
|
||||
framesEncoded: 0,
|
||||
framesSent: 0,
|
||||
framesDelta: 0,
|
||||
frameWidth: 0,
|
||||
frameHeight: 0,
|
||||
score: 0,
|
||||
};
|
||||
senderCandidates.set(id, candidate);
|
||||
log(`found video sender ${id} (${source}) track=${senderTrack?.id || 'null'}`);
|
||||
}
|
||||
|
||||
candidate.sender = sender;
|
||||
candidate.track = senderTrack || sender.track || candidate.track;
|
||||
candidate.sources.add(source);
|
||||
candidate.lastSeenAt = Date.now();
|
||||
candidate.isLocalCapture = Boolean(candidate.track && localCaptureTrackIds.has(candidate.track.id));
|
||||
senders.add(sender);
|
||||
wrapSender(sender);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function wrapSender(sender) {
|
||||
if (!sender || sender.__videoTrackProbeWrapped) return;
|
||||
const originalReplaceTrack = sender.replaceTrack?.bind(sender);
|
||||
if (!originalReplaceTrack) return;
|
||||
|
||||
sender.replaceTrack = async function (track) {
|
||||
const result = await originalReplaceTrack(track);
|
||||
if (!track || track.kind === 'video') {
|
||||
const candidate = ensureSenderCandidate(sender, track, 'replaceTrack');
|
||||
if (candidate) {
|
||||
candidate.track = track;
|
||||
log(`sender ${candidate.id} replaceTrack -> ${track?.id || 'null'}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
sender.__videoTrackProbeWrapped = true;
|
||||
}
|
||||
|
||||
function rememberReceiver(receiver, source) {
|
||||
if (!receiver?.track || !isLiveVideoTrack(receiver.track)) return;
|
||||
receivers.add(receiver);
|
||||
const candidate = ensureCandidate(receiver.track, source);
|
||||
if (candidate) candidate.receiver = receiver;
|
||||
}
|
||||
|
||||
function rememberStream(stream, source, element) {
|
||||
if (!stream || !(stream instanceof MediaStream)) return;
|
||||
for (const track of stream.getVideoTracks()) {
|
||||
const candidate = ensureCandidate(track, source);
|
||||
if (candidate && element) candidate.elements.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
function scanMediaElements() {
|
||||
document.querySelectorAll('video').forEach((element, index) => {
|
||||
rememberStream(element.srcObject, `element-${index}:video`, element);
|
||||
});
|
||||
}
|
||||
|
||||
function applyInboundVideoStat(stat, candidate) {
|
||||
const prev = inboundStatMemory.get(stat.id);
|
||||
const bytes = stat.bytesReceived || 0;
|
||||
const packets = stat.packetsReceived || 0;
|
||||
const framesReceived = stat.framesReceived || 0;
|
||||
const framesDecoded = stat.framesDecoded || 0;
|
||||
const framesDropped = stat.framesDropped || 0;
|
||||
|
||||
candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
|
||||
candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
|
||||
candidate.framesDelta = prev ? Math.max(0, framesDecoded - prev.framesDecoded) : 0;
|
||||
candidate.bytesReceived = bytes;
|
||||
candidate.packetsReceived = packets;
|
||||
candidate.framesReceived = framesReceived;
|
||||
candidate.framesDecoded = framesDecoded;
|
||||
candidate.framesDropped = framesDropped;
|
||||
candidate.frameWidth = stat.frameWidth || candidate.frameWidth;
|
||||
candidate.frameHeight = stat.frameHeight || candidate.frameHeight;
|
||||
candidate.framesPerSecond = stat.framesPerSecond || candidate.framesPerSecond;
|
||||
|
||||
if (candidate.bytesDelta > 0 || candidate.packetsDelta > 0 || candidate.framesDelta > 0) {
|
||||
candidate.lastPacketAt = Date.now();
|
||||
}
|
||||
|
||||
inboundStatMemory.set(stat.id, { bytes, packets, framesDecoded });
|
||||
}
|
||||
|
||||
function applyOutboundVideoStat(stat, candidate) {
|
||||
const prev = outboundStatMemory.get(stat.id);
|
||||
const bytes = stat.bytesSent || 0;
|
||||
const packets = stat.packetsSent || 0;
|
||||
const framesEncoded = stat.framesEncoded || 0;
|
||||
const framesSent = stat.framesSent || 0;
|
||||
|
||||
candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0;
|
||||
candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0;
|
||||
candidate.framesDelta = prev ? Math.max(0, framesSent - prev.framesSent) : 0;
|
||||
candidate.bytesSent = bytes;
|
||||
candidate.packetsSent = packets;
|
||||
candidate.framesEncoded = framesEncoded;
|
||||
candidate.framesSent = framesSent;
|
||||
candidate.frameWidth = stat.frameWidth || candidate.frameWidth;
|
||||
candidate.frameHeight = stat.frameHeight || candidate.frameHeight;
|
||||
|
||||
outboundStatMemory.set(stat.id, { bytes, packets, framesSent });
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
for (const receiver of Array.from(receivers)) {
|
||||
if (!receiver.track || receiver.track.readyState !== 'live') {
|
||||
receivers.delete(receiver);
|
||||
continue;
|
||||
}
|
||||
const candidate = ensureCandidate(receiver.track, 'receiver-stats');
|
||||
if (!candidate || !receiver.getStats) continue;
|
||||
candidate.receiver = receiver;
|
||||
|
||||
try {
|
||||
const report = await receiver.getStats();
|
||||
report.forEach((stat) => {
|
||||
const kind = stat.kind || stat.mediaType;
|
||||
if (stat.type !== 'inbound-rtp' || kind !== 'video') return;
|
||||
applyInboundVideoStat(stat, candidate);
|
||||
});
|
||||
} catch (error) {
|
||||
candidate.sources.add(`stats-error:${error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
chooseActiveTrack();
|
||||
await refreshSenderStats();
|
||||
chooseActiveSender();
|
||||
}
|
||||
|
||||
async function refreshSenderStats() {
|
||||
for (const sender of Array.from(senders)) {
|
||||
if (!sender) {
|
||||
senders.delete(sender);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = ensureSenderCandidate(sender, sender.track, 'sender-stats');
|
||||
if (!candidate || !sender.getStats) continue;
|
||||
|
||||
try {
|
||||
const report = await sender.getStats();
|
||||
report.forEach((stat) => {
|
||||
const kind = stat.kind || stat.mediaType;
|
||||
if (stat.type !== 'outbound-rtp' || kind !== 'video') return;
|
||||
applyOutboundVideoStat(stat, candidate);
|
||||
});
|
||||
} catch (error) {
|
||||
candidate.sources.add(`sender-stats-error:${error.message || error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate) {
|
||||
if (!candidate || !isLiveVideoTrack(candidate.track)) return -1;
|
||||
let score = 0;
|
||||
if (!candidate.track.muted) score += 1000;
|
||||
if (candidate.receiver) score += 300;
|
||||
if (candidate.bytesDelta > 0) score += 4000 + Math.min(1500, candidate.bytesDelta);
|
||||
if (candidate.packetsDelta > 0) score += 2000 + Math.min(800, candidate.packetsDelta * 8);
|
||||
if (candidate.framesDelta > 0) score += 3000 + Math.min(1200, candidate.framesDelta * 40);
|
||||
if (Date.now() - candidate.lastPacketAt < 3000) score += 1200;
|
||||
if (candidate.frameWidth > 0 && candidate.frameHeight > 0) score += 400;
|
||||
if (candidate.elements.size > 0) score += 200;
|
||||
if (candidate.isLocalCapture) score -= 2500;
|
||||
return score;
|
||||
}
|
||||
|
||||
function chooseActiveTrack() {
|
||||
let best = null;
|
||||
for (const candidate of candidates.values()) {
|
||||
candidate.score = scoreCandidate(candidate);
|
||||
if (!best || candidate.score > best.score) best = candidate;
|
||||
}
|
||||
if (!best || best.score < 0) return;
|
||||
if (best.track.id !== selectedTrackId) {
|
||||
selectedTrackId = best.track.id;
|
||||
log(`active video looks like ${best.track.id}, score=${Math.round(best.score)}, ${best.frameWidth}x${best.frameHeight}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scoreSenderCandidate(candidate) {
|
||||
if (!candidate) return -1;
|
||||
let score = 0;
|
||||
if (candidate.track?.readyState === 'live') score += 1000;
|
||||
if (candidate.track && !candidate.track.muted) score += 400;
|
||||
if (candidate.bytesSent > 0) score += 300;
|
||||
if (candidate.bytesDelta > 0) score += 4500 + Math.min(1800, candidate.bytesDelta);
|
||||
if (candidate.packetsDelta > 0) score += 1800 + Math.min(900, candidate.packetsDelta * 10);
|
||||
if (candidate.framesDelta > 0) score += 2200 + Math.min(1000, candidate.framesDelta * 30);
|
||||
if (candidate.frameWidth > 0 && candidate.frameHeight > 0) score += 300;
|
||||
if (candidate.isLocalCapture) score += 2500;
|
||||
if (candidate.sources.has('pc-addTrack')) score += 1500;
|
||||
return score;
|
||||
}
|
||||
|
||||
function chooseActiveSender() {
|
||||
let best = null;
|
||||
for (const candidate of senderCandidates.values()) {
|
||||
candidate.score = scoreSenderCandidate(candidate);
|
||||
if (!best || candidate.score > best.score) best = candidate;
|
||||
}
|
||||
if (!best || best.score < 0) return;
|
||||
if (best.id !== selectedSenderId) {
|
||||
selectedSenderId = best.id;
|
||||
log(`active output sender looks like ${best.id}, track=${best.track?.id || 'null'}, score=${Math.round(best.score)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dumpInputs() {
|
||||
return Array.from(candidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((candidate) => {
|
||||
const settings = trackSettings(candidate.track);
|
||||
return {
|
||||
selected: candidate.track.id === selectedTrackId,
|
||||
id: candidate.track.id,
|
||||
label: candidate.track.label,
|
||||
enabled: candidate.track.enabled,
|
||||
muted: candidate.track.muted,
|
||||
readyState: candidate.track.readyState,
|
||||
isLocalCapture: candidate.isLocalCapture,
|
||||
hasReceiver: Boolean(candidate.receiver),
|
||||
bytesReceived: candidate.bytesReceived,
|
||||
packetsReceived: candidate.packetsReceived,
|
||||
bytesDelta: candidate.bytesDelta,
|
||||
packetsDelta: candidate.packetsDelta,
|
||||
framesReceived: candidate.framesReceived,
|
||||
framesDecoded: candidate.framesDecoded,
|
||||
framesDropped: candidate.framesDropped,
|
||||
framesDelta: candidate.framesDelta,
|
||||
frameWidth: candidate.frameWidth || settings.width || 0,
|
||||
frameHeight: candidate.frameHeight || settings.height || 0,
|
||||
framesPerSecond: candidate.framesPerSecond || settings.frameRate || 0,
|
||||
score: Math.round(candidate.score),
|
||||
sources: Array.from(candidate.sources),
|
||||
elements: Array.from(candidate.elements).map((element) => ({
|
||||
tag: element.tagName,
|
||||
paused: element.paused,
|
||||
muted: element.muted,
|
||||
videoWidth: element.videoWidth,
|
||||
videoHeight: element.videoHeight,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function dumpOutputs() {
|
||||
return Array.from(senderCandidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((candidate) => {
|
||||
const settings = trackSettings(candidate.track);
|
||||
return {
|
||||
selected: candidate.id === selectedSenderId,
|
||||
id: candidate.id,
|
||||
trackId: candidate.track?.id || null,
|
||||
trackLabel: candidate.track?.label || null,
|
||||
trackEnabled: candidate.track?.enabled ?? null,
|
||||
trackMuted: candidate.track?.muted ?? null,
|
||||
trackReadyState: candidate.track?.readyState || null,
|
||||
isLocalCapture: candidate.isLocalCapture,
|
||||
bytesSent: candidate.bytesSent,
|
||||
packetsSent: candidate.packetsSent,
|
||||
bytesDelta: candidate.bytesDelta,
|
||||
packetsDelta: candidate.packetsDelta,
|
||||
framesEncoded: candidate.framesEncoded,
|
||||
framesSent: candidate.framesSent,
|
||||
framesDelta: candidate.framesDelta,
|
||||
frameWidth: candidate.frameWidth || settings.width || 0,
|
||||
frameHeight: candidate.frameHeight || settings.height || 0,
|
||||
score: Math.round(candidate.score),
|
||||
sources: Array.from(candidate.sources),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function dump() {
|
||||
return {
|
||||
selectedInputTrackId: selectedTrackId,
|
||||
selectedOutputSenderId: selectedSenderId,
|
||||
inputs: dumpInputs(),
|
||||
outputs: dumpOutputs(),
|
||||
};
|
||||
}
|
||||
|
||||
function activityPct(row) {
|
||||
return Math.min(100, Math.max(
|
||||
row.framesDelta * 8,
|
||||
row.bytesDelta / 40,
|
||||
row.packetsDelta * 2
|
||||
));
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
if (!document.body) return;
|
||||
let panel = document.getElementById('video-track-probe-panel');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'video-track-probe-panel';
|
||||
panel.style.cssText = [
|
||||
'position:fixed',
|
||||
'top:20px',
|
||||
'left:20px',
|
||||
'width:480px',
|
||||
'max-height:70vh',
|
||||
'z-index:999998',
|
||||
'box-sizing:border-box',
|
||||
'padding:12px',
|
||||
'border-radius:8px',
|
||||
'background:rgba(3,7,18,.96)',
|
||||
'color:#e5e7eb',
|
||||
'font:12px ui-monospace,Menlo,monospace',
|
||||
'box-shadow:0 12px 32px rgba(0,0,0,.35)',
|
||||
'overflow:auto',
|
||||
].join(';');
|
||||
panel.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="color:#f9a8d4;font:13px system-ui,sans-serif;">Video Track Probe</strong>
|
||||
<button id="video-track-probe-copy" style="border:0;border-radius:5px;padding:5px 8px;background:#db2777;color:white;cursor:pointer;">copy dump</button>
|
||||
</div>
|
||||
<div id="video-track-probe-summary" style="margin-bottom:8px;color:#d1d5db;"></div>
|
||||
<div style="color:#f9a8d4;margin:8px 0 4px;">Input receivers</div>
|
||||
<div id="video-track-probe-list"></div>
|
||||
<div style="color:#c084fc;margin:10px 0 4px;">Output senders</div>
|
||||
<div id="video-track-probe-output-list"></div>
|
||||
<div id="video-track-probe-log" style="margin-top:8px;height:90px;overflow:auto;border-top:1px solid #374151;padding-top:6px;color:#fbcfe8;"></div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
document.getElementById('video-track-probe-copy').addEventListener('click', async () => {
|
||||
const text = JSON.stringify(window.__videoTrackProbeDump(), null, 2);
|
||||
await navigator.clipboard?.writeText(text).catch(() => {});
|
||||
console.log('[Video Track Probe dump]', text);
|
||||
log('dump copied to clipboard and console');
|
||||
});
|
||||
}
|
||||
|
||||
const rows = dumpInputs();
|
||||
const outputRows = dumpOutputs();
|
||||
const selected = rows.find((row) => row.selected);
|
||||
const selectedOutput = outputRows.find((row) => row.selected);
|
||||
document.getElementById('video-track-probe-summary').textContent =
|
||||
`inputs=${rows.length}, selected=${selected ? selected.id : 'none'} | outputs=${outputRows.length}, selected=${selectedOutput ? selectedOutput.id : 'none'}, time=${nowText()}`;
|
||||
|
||||
document.getElementById('video-track-probe-list').innerHTML = rows.map((row) => {
|
||||
const pct = activityPct(row);
|
||||
const color = row.selected ? '#f9a8d4' : '#9ca3af';
|
||||
const res = row.frameWidth && row.frameHeight ? `${row.frameWidth}x${row.frameHeight}` : 'unknown';
|
||||
return `
|
||||
<div style="border:1px solid ${row.selected ? '#ec4899' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
|
||||
<span>${row.selected ? '*' : '-'} ${shortId(row.id)}</span>
|
||||
<span>score=${row.score}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
|
||||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f472b6,#fbbf24);"></div>
|
||||
</div>
|
||||
<div style="color:#cbd5e1;line-height:1.45;">
|
||||
${res} fps=${Number(row.framesPerSecond || 0).toFixed(1)} muted=${row.muted} receiver=${row.hasReceiver} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta}
|
||||
</div>
|
||||
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('video-track-probe-output-list').innerHTML = outputRows.map((row) => {
|
||||
const pct = activityPct(row);
|
||||
const color = row.selected ? '#c084fc' : '#9ca3af';
|
||||
const res = row.frameWidth && row.frameHeight ? `${row.frameWidth}x${row.frameHeight}` : 'unknown';
|
||||
return `
|
||||
<div style="border:1px solid ${row.selected ? '#a855f7' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
|
||||
<span>${row.selected ? '*' : '-'} ${row.id}</span>
|
||||
<span>score=${row.score}</span>
|
||||
</div>
|
||||
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
|
||||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#60a5fa,#c084fc);"></div>
|
||||
</div>
|
||||
<div style="color:#cbd5e1;line-height:1.45;">
|
||||
track=${shortId(row.trackId)} ${res} local=${row.isLocalCapture} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta}
|
||||
</div>
|
||||
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function wrapTrackListener(listener) {
|
||||
if (!listener || listener.__videoTrackProbeWrapped) return listener;
|
||||
const wrapped = function (event) {
|
||||
rememberReceiver(event?.receiver, 'pc-track');
|
||||
if (event?.track?.kind === 'video') ensureCandidate(event.track, 'pc-track');
|
||||
for (const stream of event?.streams || []) rememberStream(stream, 'pc-track-stream');
|
||||
if (typeof listener === 'function') return listener.apply(this, arguments);
|
||||
if (listener?.handleEvent) return listener.handleEvent(event);
|
||||
return undefined;
|
||||
};
|
||||
wrapped.__videoTrackProbeWrapped = true;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.addEventListener = function (type, listener, options) {
|
||||
if (type === 'track') return originalAddEventListener.call(this, type, wrapTrackListener(listener), options);
|
||||
return originalAddEventListener.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
RTCPeerConnection.prototype.addTrack = function (track, ...streams) {
|
||||
const sender = originalAddTrack.call(this, track, ...streams);
|
||||
if (track?.kind === 'video') ensureSenderCandidate(sender, track, 'pc-addTrack');
|
||||
return sender;
|
||||
};
|
||||
|
||||
if (originalAddTransceiver) {
|
||||
RTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
|
||||
const transceiver = originalAddTransceiver.call(this, trackOrKind, init);
|
||||
const kind = trackOrKind instanceof MediaStreamTrack ? trackOrKind.kind : trackOrKind;
|
||||
if (kind === 'video') {
|
||||
const track = trackOrKind instanceof MediaStreamTrack ? trackOrKind : transceiver.sender?.track;
|
||||
ensureSenderCandidate(transceiver.sender, track, 'pc-addTransceiver');
|
||||
}
|
||||
return transceiver;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack');
|
||||
Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return descriptor?.get ? descriptor.get.call(this) : this.__videoTrackProbeOnTrack || null;
|
||||
},
|
||||
set(listener) {
|
||||
const wrapped = wrapTrackListener(listener);
|
||||
this.__videoTrackProbeOnTrack = wrapped;
|
||||
if (descriptor?.set) descriptor.set.call(this, wrapped);
|
||||
else this.addEventListener('track', wrapped);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Video Track Probe] failed to wrap ontrack', error);
|
||||
}
|
||||
|
||||
RTCPeerConnection.prototype.setRemoteDescription = function () {
|
||||
const result = originalSetRemoteDescription.apply(this, arguments);
|
||||
Promise.resolve(result).then(() => {
|
||||
try {
|
||||
for (const receiver of this.getReceivers?.() || []) rememberReceiver(receiver, 'receiver-after-srd');
|
||||
for (const sender of this.getSenders?.() || []) ensureSenderCandidate(sender, sender.track, 'sender-after-srd');
|
||||
} catch (error) {
|
||||
console.warn('[Video Track Probe] failed to scan receivers', error);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
if (mediaSrcObjectDescriptor?.set) {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
|
||||
configurable: true,
|
||||
enumerable: mediaSrcObjectDescriptor.enumerable,
|
||||
get() {
|
||||
return mediaSrcObjectDescriptor.get.call(this);
|
||||
},
|
||||
set(stream) {
|
||||
rememberStream(stream, `${this.tagName.toLowerCase()}-srcObject`, this);
|
||||
return mediaSrcObjectDescriptor.set.call(this, stream);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (originalGetUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia = async function (constraints) {
|
||||
const stream = await originalGetUserMedia(constraints);
|
||||
for (const track of stream.getVideoTracks()) {
|
||||
localCaptureTrackIds.add(track.id);
|
||||
const candidate = ensureCandidate(track, 'getUserMedia');
|
||||
if (candidate) candidate.isLocalCapture = true;
|
||||
}
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
window.__videoTrackProbeDump = dump;
|
||||
|
||||
setInterval(scanMediaElements, 500);
|
||||
setInterval(refreshStats, 250);
|
||||
setInterval(() => {
|
||||
if (Date.now() - lastPanelRenderAt < 500) return;
|
||||
lastPanelRenderAt = Date.now();
|
||||
renderPanel();
|
||||
}, 250);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderPanel, { once: true });
|
||||
} else {
|
||||
renderPanel();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user