Files
ai-video-fullstack/scripts/copilot.js
Xin Wang 32a52d318a Add AI Copilot WebRTC script for enhanced audio processing and monitoring
- Introduce a new user script `copilot.js` for WebRTC that implements PCM ScriptProcessor to address packet loss issues.
- Add features for real-time audio monitoring and improved audio stream handling, including mechanisms for capturing and processing remote audio streams.
- Implement a robust audio context setup with gain nodes and oscillators for enhanced audio management.
- Enhance logging and state management for better debugging and monitoring of audio streams in the WebRTC environment.
2026-07-07 16:22:51 +08:00

933 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 12345 AI 协同中台 (WebRTC 洗流桥接版)
// @namespace http://tampermonkey.net/
// @version 7.5
// @description 引入 PCM ScriptProcessor 强制软解防丢包Bug增加真实洗流耳返监听功能彻底解决远端触发失败悬案
// @author You
// @match *://yhy-yw.22sj.cn:*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
console.log("🚀 [AI Copilot WebRTC] 注入脚本已加载,采用洗流桥接架构 v7.5 (强制 PCM 软解+耳返版)...");
// ==========================================
// 1. 全局状态与 WebRTC 核心对象
// ==========================================
let originalGetUserMedia = null;
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
}
const origAddEventListener = RTCPeerConnection.prototype.addEventListener;
const origSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
const origAddStream = RTCPeerConnection.prototype.addStream;
const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
let audioCtx = null;
let meterAudioCtx = null;
let meterUploadDestination = null;
let micGainNode = null;
let aiOutGainNode = null;
let aiLocalGainNode = null;
let washedUploadTrack = null;
let keepAliveOscillator = null;
let monitorGainNode = null; // 耳返监听控制器
let pc = null;
let wsSignaling = null;
let dc = null;
let audioSender = null;
let videoSender = null;
let analyserNode = null;
let isAiTakeover = false;
let isMonitoring = false;
window.__allStolenStreams = new Set();
window.__ignoredTrackIds = new Set();
let currentStolenAudioTrack = null;
let currentStolenVideoTrack = null;
let currentMeterSourceNode = null;
let meterKeepAliveGainNode = null;
let pendingBridgeAudioTrack = null;
let statsVuLevel = 0;
const remoteReceiversByTrackId = new Map();
const receiverStatsMemory = new Map();
function isInternalPeerConnection(target) {
return !!(target && target.__aiCopilotInternal);
}
function generatePcId() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return "PC-" + Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function isSafeToSteal(stream) {
if (!stream || !(stream instanceof MediaStream)) return false;
if (stream.__internal_bypass) return false;
if (stream.getTracks().length === 0) return false;
for (const track of stream.getTracks()) {
if (window.__ignoredTrackIds.has(track.id)) return false;
}
return true;
}
function rememberRemoteStream(stream, reason = 'unknown') {
if (!isSafeToSteal(stream)) return;
window.__allStolenStreams.add(stream);
console.log(`[AI Copilot WebRTC] 捕获候选远端流(${reason})`, stream.id, stream.getTracks().map(t => `${t.kind}:${t.readyState}:${t.id}`));
}
function rememberRemoteTrack(track, stream, reason = 'track') {
if (!track || window.__ignoredTrackIds.has(track.id)) return;
const targetStream = isSafeToSteal(stream) ? stream : new MediaStream([track]);
rememberRemoteStream(targetStream, reason);
}
function rememberRemoteReceiver(track, receiver, reason = 'receiver') {
if (!track || !receiver || window.__ignoredTrackIds.has(track.id)) return;
remoteReceiversByTrackId.set(track.id, receiver);
console.log(`[AI Copilot WebRTC] 绑定远端 receiver(${reason})`, track.kind, track.id);
}
function initMeterAudioCtx() {
if (meterAudioCtx) return;
try {
meterAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
meterKeepAliveGainNode = meterAudioCtx.createGain();
meterKeepAliveGainNode.gain.value = 0;
meterKeepAliveGainNode.connect(meterAudioCtx.destination);
meterUploadDestination = meterAudioCtx.createMediaStreamDestination();
meterUploadDestination.stream.__internal_bypass = true;
washedUploadTrack = meterUploadDestination.stream.getAudioTracks()[0];
window.__ignoredTrackIds.add(washedUploadTrack.id);
analyserNode = meterAudioCtx.createAnalyser();
analyserNode.fftSize = 256;
analyserNode.smoothingTimeConstant = 0.35;
// 耳返监听节点,默认为静音 (0)
monitorGainNode = meterAudioCtx.createGain();
monitorGainNode.gain.value = 0;
monitorGainNode.connect(meterAudioCtx.destination);
keepAliveOscillator = meterAudioCtx.createOscillator();
const oscGain = meterAudioCtx.createGain();
oscGain.gain.value = 0.0001;
keepAliveOscillator.connect(oscGain);
oscGain.connect(meterUploadDestination);
keepAliveOscillator.start();
console.log("🛠️ 洗流音频总线与保活振荡器已创建");
} catch(e) {
console.warn("VU 探测链路初始化失败:", e);
}
}
function bridgeRemoteAudioTrack(track, reason = 'scan') {
if (!track || track.readyState !== 'live' || window.__ignoredTrackIds.has(track.id)) return;
if (track === currentStolenAudioTrack && currentMeterSourceNode) return;
pendingBridgeAudioTrack = track;
appendLog(`🎙️ 锁定远端发声轨(${reason}),接入混流总线`, true);
currentStolenAudioTrack = track;
ensureVuMeterForTrack(track, reason);
}
// ==========================================
// 🛑 核心神技PCM Passthrough 强制阻断 Chrome 硬件流 Bug
// ==========================================
let pcmForcerNode = null;
function ensureVuMeterForTrack(track, reason = 'unknown') {
if (!track || track.readyState !== 'live') return;
initMeterAudioCtx();
try {
if (currentMeterSourceNode) {
try { currentMeterSourceNode.disconnect(); } catch(e) {}
}
if (pcmForcerNode) {
try { pcmForcerNode.disconnect(); } catch(e) {}
}
const meterStream = new MediaStream([track]);
meterStream.__internal_bypass = true;
// 1. 回归 MediaStreamSource但用 ScriptProcessor 打断它
currentMeterSourceNode = meterAudioCtx.createMediaStreamSource(meterStream);
// 2. AGC 放大增益 (击穿 VAD 阈值)
const vadBoostGain = meterAudioCtx.createGain();
vadBoostGain.gain.value = 8.0; // 放大 8 倍
currentMeterSourceNode.connect(vadBoostGain);
// 3. 终极杀招PCM 强刷节点。迫使浏览器离开底层 WebRTC 硬件直通层,进入 JS 内存读取浮点数!
// 缓存大小 2048/4096 皆可。1进1出。
pcmForcerNode = meterAudioCtx.createScriptProcessor(4096, 1, 1);
pcmForcerNode.onaudioprocess = function(audioProcessingEvent) {
const inputBuffer = audioProcessingEvent.inputBuffer;
const outputBuffer = audioProcessingEvent.outputBuffer;
// 将输入通道的 float32 数据原封不动写到输出通道
const inputData = inputBuffer.getChannelData(0);
const outputData = outputBuffer.getChannelData(0);
for (let i = 0; i < inputBuffer.length; i++) {
outputData[i] = inputData[i];
}
};
// 路由:源 -> 放大器 -> PCM强刷节点 -> (VU表 / WebRTC发送端 / 耳返节点 / 垃圾桶)
vadBoostGain.connect(pcmForcerNode);
pcmForcerNode.connect(analyserNode);
pcmForcerNode.connect(meterUploadDestination);
pcmForcerNode.connect(monitorGainNode);
// 必须连接到音频输出才能触发 onaudioprocess用 KeepAliveGainNode(增益为0) 当作垃圾桶
pcmForcerNode.connect(meterKeepAliveGainNode);
window.__aiCopilotMeterSource = currentMeterSourceNode;
if (meterAudioCtx.state === 'suspended') {
meterAudioCtx.resume().catch(e => console.warn("VU 音频上下文唤醒失败:", e));
}
appendLog(`📈 洗流管道已接管,注入 PCM 强刷节点(${reason})`, true);
} catch(e) {
console.warn("接入混流总线失败:", e);
}
}
function armAudioContextResume() {
const resume = () => {
[meterAudioCtx, audioCtx].forEach(ctx => {
if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => {});
});
};
document.addEventListener('pointerdown', resume, true);
document.addEventListener('keydown', resume, true);
document.addEventListener('touchstart', resume, true);
}
armAudioContextResume();
function bridgeRemoteVideoTrack(track) {
if (!track || track.readyState !== 'live' || track === currentStolenVideoTrack || window.__ignoredTrackIds.has(track.id)) return;
currentStolenVideoTrack = track;
appendLog(`📸 锁定远端视频,开启视觉推流`, true);
const indicator = document.getElementById('video-status-indicator');
if (indicator) indicator.style.display = 'block';
if (videoSender) videoSender.replaceTrack(currentStolenVideoTrack).catch(e => console.warn(e));
}
function appendLog(message, isImportant = false) {
const logContainer = document.getElementById('ws-log-container');
if (!logContainer) return;
if (logContainer.children.length === 1 && logContainer.children[0].innerText.includes('等待 DataChannel')) {
logContainer.innerHTML = '';
}
const logEntry = document.createElement('div');
logEntry.style.marginBottom = "4px";
if (isImportant) logEntry.style.color = "#fcd34d";
const time = new Date().toLocaleTimeString([], {hour12: false});
logEntry.textContent = `[${time}] ${message}`;
logContainer.appendChild(logEntry);
if (logContainer.children.length > 100) {
logContainer.removeChild(logContainer.firstChild);
}
logContainer.scrollTop = logContainer.scrollHeight;
}
// VU 表渲染
let vuDataArray = null;
function renderVU() {
let rms = 0;
if (analyserNode) {
if (!vuDataArray || vuDataArray.length !== analyserNode.frequencyBinCount) {
vuDataArray = new Uint8Array(analyserNode.frequencyBinCount);
}
analyserNode.getByteTimeDomainData(vuDataArray);
let sumSquare = 0;
for(let i = 0; i < vuDataArray.length; i++) {
const val = (vuDataArray[i] - 128) / 128;
sumSquare += val * val;
}
rms = Math.sqrt(sumSquare / vuDataArray.length);
}
rms = Math.max(rms, statsVuLevel);
statsVuLevel *= 0.88;
const vuBar = document.getElementById('upload-vu-bar');
if (vuBar) {
const width = Math.min(100, rms * 2500);
vuBar.style.width = width + '%';
}
requestAnimationFrame(renderVU);
}
requestAnimationFrame(renderVU);
setInterval(async () => {
const receivers = Array.from(remoteReceiversByTrackId.values());
let bestLevel = 0;
for (const receiver of receivers) {
try {
if (!receiver.track || receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue;
const report = await receiver.getStats();
report.forEach(stat => {
const statKind = stat.kind || stat.mediaType;
if (stat.type !== 'inbound-rtp' || statKind !== 'audio') return;
if (typeof stat.audioLevel === 'number') {
bestLevel = Math.max(bestLevel, stat.audioLevel);
}
if (typeof stat.totalAudioEnergy === 'number' && typeof stat.totalSamplesDuration === 'number') {
const prev = receiverStatsMemory.get(stat.id);
receiverStatsMemory.set(stat.id, {
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) {
bestLevel = Math.max(bestLevel, Math.sqrt(energyDelta / durationDelta));
}
}
}
});
} catch(e) {}
}
if (bestLevel > 0) statsVuLevel = Math.max(statsVuLevel, bestLevel);
}, 250);
// ==========================================
// 2. 精准声源窃取拦截器
// ==========================================
function wrapTrackListener(target, listener) {
if (!listener || listener.__aiCopilotWrappedTrackListener) return listener;
const wrapped = function(event) {
if (!isInternalPeerConnection(this) && event && event.track) {
rememberRemoteTrack(event.track, event.streams && event.streams[0], 'pc-track');
rememberRemoteReceiver(event.track, event.receiver, 'pc-track');
}
if (typeof listener === 'function') return listener.apply(this, arguments);
if (typeof listener === 'object' && listener.handleEvent) return listener.handleEvent(event);
};
wrapped.__aiCopilotWrappedTrackListener = true;
wrapped.__aiCopilotOriginalTrackListener = listener;
return wrapped;
}
function wrapAddStreamListener(listener) {
if (!listener || listener.__aiCopilotWrappedAddStreamListener) return listener;
const wrapped = function(event) {
if (!isInternalPeerConnection(this) && event && event.stream) {
rememberRemoteStream(event.stream, 'pc-addstream');
}
if (typeof listener === 'function') return listener.apply(this, arguments);
if (typeof listener === 'object' && listener.handleEvent) return listener.handleEvent(event);
};
wrapped.__aiCopilotWrappedAddStreamListener = true;
return wrapped;
}
RTCPeerConnection.prototype.addEventListener = function(type, listener, options) {
if (type === 'track') {
return origAddEventListener.call(this, type, wrapTrackListener(this, listener), options);
}
if (type === 'addstream') {
return origAddEventListener.call(this, type, wrapAddStreamListener(listener), options);
}
return origAddEventListener.call(this, type, listener, options);
};
try {
const onTrackDescriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack');
Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', {
configurable: true,
enumerable: true,
get: function() {
return onTrackDescriptor && onTrackDescriptor.get ? onTrackDescriptor.get.call(this) : this.__aiCopilotOnTrack || null;
},
set: function(listener) {
const wrapped = wrapTrackListener(this, listener);
this.__aiCopilotOnTrack = wrapped;
if (onTrackDescriptor && onTrackDescriptor.set) {
onTrackDescriptor.set.call(this, wrapped);
} else {
this.addEventListener('track', wrapped);
}
}
});
} catch (e) {
console.warn("ontrack 拦截安装失败:", e);
}
try {
const onAddStreamDescriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'onaddstream');
Object.defineProperty(RTCPeerConnection.prototype, 'onaddstream', {
configurable: true,
enumerable: true,
get: function() {
return onAddStreamDescriptor && onAddStreamDescriptor.get ? onAddStreamDescriptor.get.call(this) : this.__aiCopilotOnAddStream || null;
},
set: function(listener) {
const wrapped = wrapAddStreamListener(listener);
this.__aiCopilotOnAddStream = wrapped;
if (onAddStreamDescriptor && onAddStreamDescriptor.set) {
onAddStreamDescriptor.set.call(this, wrapped);
} else {
this.addEventListener('addstream', wrapped);
}
}
});
} catch (e) {
console.warn("onaddstream 拦截安装失败:", e);
}
RTCPeerConnection.prototype.setRemoteDescription = function() {
const ret = origSetRemoteDescription.apply(this, arguments);
if (!isInternalPeerConnection(this)) {
Promise.resolve(ret).then(() => {
try {
this.getReceivers?.().forEach(receiver => {
if (receiver.track) {
rememberRemoteTrack(receiver.track, new MediaStream([receiver.track]), 'receiver-after-srd');
rememberRemoteReceiver(receiver.track, receiver, 'receiver-after-srd');
}
});
this.getRemoteStreams?.().forEach(stream => rememberRemoteStream(stream, 'remote-stream-after-srd'));
} catch (e) {}
});
}
return ret;
};
if (origAddStream) {
RTCPeerConnection.prototype.addStream = function(stream) {
if (!isInternalPeerConnection(this)) rememberRemoteStream(stream, 'pc-addStream-call');
return origAddStream.apply(this, arguments);
};
}
if (mediaSrcObjectDescriptor && mediaSrcObjectDescriptor.set) {
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
enumerable: mediaSrcObjectDescriptor.enumerable,
get: function() {
return mediaSrcObjectDescriptor.get.call(this);
},
set: function(stream) {
if ((this.tagName === 'AUDIO' || this.tagName === 'VIDEO') && !this.__aiCopilotInternalElement) {
rememberRemoteStream(stream, 'media-srcObject');
}
return mediaSrcObjectDescriptor.set.call(this, stream);
}
});
}
const origPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
if (!this.muted && this.volume > 0 && isSafeToSteal(this.srcObject)) {
rememberRemoteStream(this.srcObject, 'media-play');
}
return origPlay.apply(this, arguments);
};
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const origCreateMediaStreamSource = AudioContextCtor?.prototype?.createMediaStreamSource;
if (AudioContextCtor && origCreateMediaStreamSource) {
AudioContextCtor.prototype.createMediaStreamSource = function(stream) {
if (isSafeToSteal(stream)) {
rememberRemoteStream(stream, 'audio-context-source');
}
return origCreateMediaStreamSource.call(this, stream);
};
}
setInterval(() => {
document.querySelectorAll('audio, video').forEach(el => {
if (!el.muted && el.volume > 0 && isSafeToSteal(el.srcObject)) {
rememberRemoteStream(el.srcObject, 'media-scan');
}
});
let foundVideoTrack = null;
let foundAudioTrack = null;
const mediaEls = document.querySelectorAll('audio, video');
for (let el of mediaEls) {
if (el.__aiCopilotInternalElement) continue;
if (!el.muted && el.volume > 0 && isSafeToSteal(el.srcObject)) {
if (!foundVideoTrack) {
const vTracks = el.srcObject.getVideoTracks().filter(t => t.readyState === 'live');
if (vTracks.length > 0) foundVideoTrack = vTracks[0];
}
if (!foundAudioTrack) {
const aTracks = el.srcObject.getAudioTracks().filter(t => t.readyState === 'live');
if (aTracks.length > 0) foundAudioTrack = aTracks[0];
}
}
}
if (!foundAudioTrack || !foundVideoTrack) {
for (const stream of Array.from(window.__allStolenStreams)) {
if (!foundVideoTrack) {
const videoTracks = stream.getVideoTracks().filter(t => t.readyState === 'live');
if (videoTracks.length > 0) foundVideoTrack = videoTracks[0];
}
if (!foundAudioTrack) {
const audioTracks = stream.getAudioTracks().filter(t => t.readyState === 'live');
if (audioTracks.length > 0) foundAudioTrack = audioTracks[0];
}
if (foundVideoTrack && foundAudioTrack) break;
}
}
bridgeRemoteVideoTrack(foundVideoTrack);
bridgeRemoteAudioTrack(foundAudioTrack, 'scan');
}, 1000);
// ==========================================
// 3. 覆写 getUserMedia 构建本地混音路由
// ==========================================
if (originalGetUserMedia) {
navigator.mediaDevices.getUserMedia = async function (constraints) {
if (constraints.video && !constraints.audio) {
renderControlPanelSoon();
return originalGetUserMedia(constraints);
}
try {
const realStream = await originalGetUserMedia(constraints);
realStream.__internal_bypass = true;
realStream.getTracks().forEach(t => window.__ignoredTrackIds.add(t.id));
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const mixedDest = audioCtx.createMediaStreamDestination();
mixedDest.stream.__internal_bypass = true;
mixedDest.stream.getTracks().forEach(t => window.__ignoredTrackIds.add(t.id));
if (realStream.getAudioTracks().length > 0) {
const localMicSource = audioCtx.createMediaStreamSource(realStream);
micGainNode = audioCtx.createGain();
micGainNode.gain.value = isAiTakeover ? 0 : 1.0;
localMicSource.connect(micGainNode);
micGainNode.connect(mixedDest);
}
aiOutGainNode = audioCtx.createGain();
aiOutGainNode.gain.value = isAiTakeover ? 1.0 : 0;
aiOutGainNode.connect(mixedDest);
aiLocalGainNode = audioCtx.createGain();
aiLocalGainNode.gain.value = isAiTakeover ? 1.0 : 0;
aiLocalGainNode.connect(audioCtx.destination);
if (constraints.video && realStream.getVideoTracks().length > 0) {
mixedDest.stream.addTrack(realStream.getVideoTracks()[0]);
}
if (pendingBridgeAudioTrack && pendingBridgeAudioTrack.readyState === 'live') {
bridgeRemoteAudioTrack(pendingBridgeAudioTrack, 'pending');
}
renderControlPanelSoon();
return mixedDest.stream;
} catch (err) {
console.error("🚨 拦截失败:", err);
return originalGetUserMedia(constraints);
}
};
}
// ==========================================
// 4. WebRTC 核心信令
// ==========================================
async function connectWebRTC(wsUrl, assistantId) {
disconnectWebRTC();
try {
const pcId = generatePcId();
pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
pc.__aiCopilotInternal = true;
const statusEl = document.getElementById('ws-status');
if(statusEl) statusEl.textContent = "🟡 协商中...";
appendLog("🔄 正在建立 WebRTC 连接...");
initMeterAudioCtx();
const activeStream = new MediaStream([washedUploadTrack]);
const audioTransceiver = pc.addTransceiver(washedUploadTrack, {
direction: 'sendrecv',
streams: [activeStream]
});
audioSender = audioTransceiver.sender;
appendLog("🎤 已挂载洗流音频管线 (附带 PCM 强刷保障)", true);
const videoTransceiver = pc.addTransceiver('video', { direction: 'sendonly' });
videoSender = videoTransceiver.sender;
if (currentStolenVideoTrack) {
videoSender.replaceTrack(currentStolenVideoTrack).catch(e=>console.warn(e));
}
pc.ontrack = (e) => {
if (e.track.kind === 'audio' && audioCtx) {
appendLog("🎧 接收到原生 AI 语音流,接入本地拓扑", true);
window.__ignoredTrackIds.add(e.track.id);
const aiStream = new MediaStream([e.track]);
aiStream.__internal_bypass = true;
const aiStreamSource = audioCtx.createMediaStreamSource(aiStream);
aiStreamSource.connect(aiOutGainNode);
aiStreamSource.connect(aiLocalGainNode);
window.__aiStreamSource = aiStreamSource;
}
};
dc = pc.createDataChannel("chat");
dc.onopen = () => {
const statusEl = document.getElementById('ws-status');
if(statusEl) statusEl.textContent = "🟢 已连接";
appendLog("✅ WebRTC 数据通道建立成功");
dc.send(JSON.stringify({ type: "client-ready" }));
};
dc.onclose = () => {
const statusEl = document.getElementById('ws-status');
if(statusEl) statusEl.textContent = "🔴 未连接";
appendLog("❌ WebRTC 连接断开");
};
dc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'assistant-text-delta' || msg.type === 'transcript') {
const content = msg.delta || msg.content;
if (content && content.trim()) appendLog(`💬 AI: ${content}`);
} else if (msg.type === 'node-active') {
appendLog(`🔄 工作流流转至节点: ${msg.nodeId}`, true);
} else if (msg.type !== 'assistant-text-start' && msg.type !== 'assistant-text-end') {
appendLog(`📥 收到信令: ${msg.type}`);
}
} catch (e) {
appendLog(`📥 收到文本: ${event.data}`);
}
};
wsSignaling = new WebSocket(wsUrl);
wsSignaling.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "answer") {
await pc.setRemoteDescription({ type: "answer", sdp: msg.payload.sdp });
appendLog("📤 SDP 握手成功,底层引擎接管音视频传输", true);
} else if (msg.type === "ice-candidate" && msg.payload?.candidate) {
try { await pc.addIceCandidate(msg.payload.candidate); } catch(e){}
} else if (msg.type === "error") {
throw new Error(msg.payload?.message || "后端报错");
}
} catch (e) {
appendLog("⚠️ 信令处理错误: " + e.message);
}
};
wsSignaling.onopen = async () => {
pc.onicecandidate = (e) => {
if (wsSignaling.readyState !== WebSocket.OPEN) return;
wsSignaling.send(JSON.stringify({
type: "ice-candidate",
payload: {
pc_id: pcId,
candidate: e.candidate ? {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex
} : null
}
}));
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
wsSignaling.send(JSON.stringify({
type: "offer",
payload: {
pc_id: pcId,
sdp: pc.localDescription.sdp,
type: pc.localDescription.type,
assistant_id: assistantId,
vision_enabled: true
}
}));
};
wsSignaling.onerror = () => appendLog("⚠️ WebSocket 信令连接失败");
} catch (e) {
console.error(e);
appendLog("⚠️ 连接失败: " + e.message);
const statusEl = document.getElementById('ws-status');
if (statusEl) statusEl.textContent = "🔴 连接失败";
}
}
function disconnectWebRTC() {
if (dc) {
try { dc.close(); } catch(e){}
dc = null;
}
if (pc) {
pc.close();
pc = null;
}
if (wsSignaling) {
try { wsSignaling.close(); } catch(e){}
wsSignaling = null;
}
const statusEl = document.getElementById('ws-status');
if (statusEl) statusEl.textContent = "🔴 未连接";
appendLog("⏹️ 已断开 WebRTC 引擎连接");
}
// ==========================================
// 5. 渲染控制面板
// ==========================================
function renderControlPanel() {
if (document.getElementById('ai-copilot-panel')) return;
if (!document.body) return;
const panel = document.createElement('div');
panel.id = 'ai-copilot-panel';
panel.style.cssText = `
position: fixed; top: 20px; left: 20px;
background: rgba(17, 24, 39, 0.95); color: white;
padding: 20px; border-radius: 12px; z-index: 999999;
font-family: sans-serif; box-shadow: 0 10px 25px rgba(0,0,0,0.5);
width: 340px; backdrop-filter: blur(10px);
max-height: 90vh; overflow-y: auto;
`;
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #374151; padding-bottom: 10px; margin-bottom: 15px;">
<h3 style="margin: 0; font-size: 16px; font-weight: bold; color: #60a5fa;">🤖 AI WebRTC 引擎枢纽</h3>
<span id="ws-status" style="font-size: 12px; font-weight: bold; color: #9ca3af;">🔴 未连接</span>
</div>
<div style="margin-bottom: 10px;">
<label style="display: block; font-size: 12px; color: #9ca3af; margin-bottom: 5px;">WebSocket 信令地址 (/ws/voice):</label>
<input type="text" id="ws-url-input" value="ws://182.92.86.220:8000/ws/voice" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #4b5563; background: #1f2937; color: white; box-sizing: border-box; font-size: 13px;">
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 12px; color: #9ca3af; margin-bottom: 5px;">Assistant ID (必填):</label>
<input type="text" id="assistant-id-input" placeholder="输入要唤醒的助手 ID" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #4b5563; background: #1f2937; color: white; box-sizing: border-box; font-size: 13px;">
</div>
<div style="margin-bottom: 15px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
<span style="font-size: 12px; color: #d1fae5; font-weight: 700;">当事人声音 (AI入站音量)</span>
<button id="btn-toggle-monitor" style="background: #ef4444; border: none; color: white; border-radius: 4px; font-size: 10px; padding: 2px 6px; cursor: pointer;">
🎧 返听已关
</button>
</div>
<div style="background: #374151; height: 8px; border-radius: 4px; overflow: hidden;" title="跳动说明坐席端已捕获当事人远端音频">
<div id="upload-vu-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #10b981, #fcd34d); transition: width 0.05s ease-out;"></div>
</div>
</div>
<div id="video-status-indicator" style="display: none; margin-bottom: 15px; padding: 6px; background: #064e3b; border: 1px solid #059669; border-radius: 4px; text-align: center;">
<span style="font-size: 12px; color: #34d399;">📹 实时视频视觉串流通道激活</span>
</div>
<div style="border-top: 1px solid #374151; padding-top: 15px;">
<button id="btn-toggle-takeover" style="width: 100%; padding: 15px; cursor: pointer; background: #10b981; border: none; color: white; border-radius: 6px; font-weight: bold; font-size: 16px; transition: 0.3s;">
👨‍💼 人工通话中 (点击 AI 托管)
</button>
</div>
<div style="margin-top: 10px; display: flex; gap: 8px;">
<button id="btn-test-text" style="flex: 1; padding: 10px; cursor: pointer; background: #3b82f6; border: none; color: white; border-radius: 6px; font-weight: bold; font-size: 12px; transition: 0.3s;" title="通过 DataChannel 直接发送文本给大模型">
💬 文本测试
</button>
<button id="btn-test-voice" style="flex: 1; padding: 10px; cursor: pointer; background: #8b5cf6; border: none; color: white; border-radius: 6px; font-weight: bold; font-size: 12px; transition: 0.3s;" title="选择本地音频文件注入 WebRTC">
🎙️ 本地音频
</button>
</div>
<div style="border-top: 1px solid #374151; padding-top: 15px; margin-top: 15px;">
<p style="font-size: 12px; margin: 0 0 5px 0; color: #9ca3af;">DataChannel 信令日志</p>
<div id="ws-log-container" style="height: 140px; overflow-y: auto; background: #111827; border: 1px solid #4b5563; border-radius: 6px; padding: 8px; font-size: 11px; font-family: monospace; color: #a7f3d0; display: flex; flex-direction: column;">
<span style="color: #6b7280; font-style: italic;">等待 DataChannel 消息...</span>
</div>
</div>
`;
document.body.appendChild(panel);
const btnToggleTakeover = document.getElementById('btn-toggle-takeover');
const btnTestText = document.getElementById('btn-test-text');
const btnTestVoice = document.getElementById('btn-test-voice');
const btnToggleMonitor = document.getElementById('btn-toggle-monitor');
const urlInput = document.getElementById('ws-url-input');
const assistantIdInput = document.getElementById('assistant-id-input');
// 耳返功能(直接排查法)
btnToggleMonitor.addEventListener('click', () => {
if (!monitorGainNode) return alert("总线尚未初始化!");
isMonitoring = !isMonitoring;
monitorGainNode.gain.value = isMonitoring ? 1.0 : 0;
if (isMonitoring) {
btnToggleMonitor.textContent = "🎧 返听已开";
btnToggleMonitor.style.background = "#10b981";
appendLog("🎧 已开启硬件级返听,耳机听到的即是发给 AI 的声音", true);
} else {
btnToggleMonitor.textContent = "🎧 返听已关";
btnToggleMonitor.style.background = "#ef4444";
appendLog("🔇 返听已关闭");
}
});
btnTestText.addEventListener('click', () => {
if (!dc || dc.readyState !== 'open') {
alert("⚠️ DataChannel 尚未连接请先点击【AI 托管】。");
return;
}
const testText = "你好,你是谁";
dc.send(JSON.stringify({ type: "user-text", text: testText }));
appendLog(`📝 发送强制测音文本: ${testText}`, true);
});
btnTestVoice.addEventListener('click', () => {
if (!meterAudioCtx || !meterUploadDestination) {
alert("⚠️ 洗流总线未初始化请先进行【AI 托管】连接。");
return;
}
if (meterAudioCtx.state === 'suspended') {
meterAudioCtx.resume();
}
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'audio/*';
fileInput.style.display = 'none';
fileInput.onchange = (e) => {
const file = e.target.files[0];
if (!file) {
fileInput.remove();
return;
}
appendLog(`🔄 正在加载本地音频: ${file.name}...`);
const blobUrl = URL.createObjectURL(file);
const audioEl = new Audio();
audioEl.src = blobUrl;
const fileSource = meterAudioCtx.createMediaElementSource(audioEl);
fileSource.connect(meterUploadDestination);
if (analyserNode) fileSource.connect(analyserNode);
fileSource.connect(meterAudioCtx.destination);
audioEl.play().then(() => {
appendLog(`🗣️ 已成功将本地音频注入 WebRTC 音轨: ${file.name}`, true);
}).catch(err => {
appendLog(`❌ 注入音频播放失败: ${err.message}`);
console.error("本地音频播放错误:", err);
});
audioEl.onended = () => {
URL.revokeObjectURL(blobUrl);
appendLog(`⏹️ 本地音频播报完毕: ${file.name}`);
};
fileInput.remove();
};
document.body.appendChild(fileInput);
fileInput.click();
});
btnToggleTakeover.addEventListener('click', () => {
if (!audioCtx) {
alert("音频路由未初始化!请先在网页内加入会议。");
return;
}
const asstId = assistantIdInput.value.trim();
if (!isAiTakeover && !asstId) {
alert("请先填写 Assistant ID 再发起连接!");
return;
}
isAiTakeover = !isAiTakeover;
const now = audioCtx.currentTime;
if (isAiTakeover) {
if(micGainNode) micGainNode.gain.setValueAtTime(0, now);
if(aiOutGainNode) aiOutGainNode.gain.setValueAtTime(1.0, now);
if(aiLocalGainNode) aiLocalGainNode.gain.setValueAtTime(1.0, now);
btnToggleTakeover.textContent = "🤖 AI 托管中 (点击切回人工)";
btnToggleTakeover.style.background = "#8b5cf6";
if (!pc || pc.connectionState !== 'connected') {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
if (meterAudioCtx && meterAudioCtx.state === 'suspended') {
meterAudioCtx.resume().catch(e => console.warn("meterAudioCtx 唤醒失败", e));
}
connectWebRTC(urlInput.value, asstId);
}
} else {
if(micGainNode) micGainNode.gain.setValueAtTime(1.0, now);
if(aiOutGainNode) aiOutGainNode.gain.setValueAtTime(0, now);
if(aiLocalGainNode) aiLocalGainNode.gain.setValueAtTime(0, now);
btnToggleTakeover.textContent = "👨‍💼 人工通话中 (点击 AI 托管)";
btnToggleTakeover.style.background = "#10b981";
disconnectWebRTC();
}
});
}
function renderControlPanelSoon() {
try { renderControlPanel(); } catch(e) {}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderControlPanelSoon, { once: true });
} else {
renderControlPanelSoon();
}
setTimeout(renderControlPanelSoon, 1500);
setTimeout(renderControlPanelSoon, 3000);
// ==========================================
// 🛑 UI 守护进程
// ==========================================
setInterval(() => {
if (document.body && !document.getElementById('ai-copilot-panel')) {
console.log("⚠️ [AI Copilot] 检测到面板被网页框架覆盖,正在重新挂载...");
renderControlPanelSoon();
if (pc && pc.connectionState === 'connected') {
const statusEl = document.getElementById('ws-status');
if (statusEl) statusEl.textContent = "🟢 已连接";
const btn = document.getElementById('btn-toggle-takeover');
if (btn && isAiTakeover) {
btn.textContent = "🤖 AI 托管中 (点击切回人工)";
btn.style.background = "#8b5cf6";
}
}
}
}, 2000);
})();