// ==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 = `