From d3661adf464fcc9fe5195c84b60711b496159a28 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 9 Jul 2026 09:39:38 +0800 Subject: [PATCH] Add Audio Track Probe script for enhanced audio monitoring - Introduce a new user script `audio-track-probe.js` that probes WebRTC audio input/output tracks and displays active RTP statistics. - Implement functionality to manage audio track candidates, including live audio detection and audio level analysis. - Enhance logging features to provide real-time feedback on audio track status and metrics. - Integrate audio context management for improved audio visualization and monitoring capabilities. --- scripts/audio-track-probe.js | 652 +++++++++++++++++++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 scripts/audio-track-probe.js 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 = ` +
+ 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(); + } +})();