Files
ai-video-fullstack/scripts/audio-sender-sine-tester.js
Xin Wang 03478fa557 Add Audio Sender Sine Tester script for WebRTC audio management
- Introduce a new user script `audio-sender-sine-tester.js` that lists current WebRTC audio senders and allows temporary sine wave transmission through selected senders.
- Implement functionality for managing audio sender candidates, tracking audio statistics, and providing real-time logging of audio activities.
- Enhance audio testing capabilities with sine wave generation and track replacement features, improving overall audio management in the WebRTC environment.
- This addition supports better monitoring and testing of audio streams in applications utilizing WebRTC.
2026-07-09 13:33:02 +08:00

372 lines
16 KiB
JavaScript

// ==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();
}
})();