diff --git a/scripts/audio-track-probe.js b/scripts/audio-track-probe.js new file mode 100644 index 0000000..0b33f13 --- /dev/null +++ b/scripts/audio-track-probe.js @@ -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 = ` +