// ==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 = `
Audio Track Probe
Input receivers
Output senders
`; 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 `
${row.selected ? '*' : '-'} ${shortId(row.id)} ${volumeText(row.audioLevel)} score=${row.score}
muted=${row.muted} receiver=${row.hasReceiver} bytesDelta=${row.bytesDelta} packetsDelta=${row.packetsDelta} level=${row.audioLevel.toFixed(5)}
${row.sources.join(', ')}
`; }).join(''); document.getElementById('audio-track-probe-output-list').innerHTML = outputRows.map((row) => { const pct = row.volumePercent; const color = row.selected ? '#c084fc' : '#9ca3af'; return `
${row.selected ? '*' : '-'} ${row.id} ${volumeText(row.audioLevel)} score=${row.score}
track=${shortId(row.trackId)} muted=${row.trackMuted} bytesDelta=${row.bytesDelta} packetsDelta=${row.packetsDelta} level=${Number(row.audioLevel || 0).toFixed(5)}
${row.sources.join(', ')}
`; }).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(); } })();