'use strict';
const state = {
config: null,
audioContext: null,
rawStream: null,
processedStream: null,
sourceNode: null,
micGainNode: null,
limiterNode: null,
relayNode: null,
relaySinkNode: null,
relaySequence: 0,
relayCaptureActive: false,
peers: new Map(),
participantNames: new Map(),
micMuted: false,
noiseSuppression: true,
currentDeviceId: '',
initialized: false,
stopping: false,
disposed: false,
metricsTimer: null
};
let hostMessageChain = Promise.resolve();
const ui = {
status: document.getElementById('statusText'),
meshBadge: document.getElementById('meshBadge'),
noiseBadge: document.getElementById('noiseBadge'),
turnBadge: document.getElementById('turnBadge'),
participantCount: document.getElementById('participantCount'),
roundTrip: document.getElementById('roundTrip'),
jitter: document.getElementById('jitter'),
packetLoss: document.getElementById('packetLoss'),
grid: document.getElementById('participantGrid'),
template: document.getElementById('participantTemplate'),
microphone: document.getElementById('microphoneSelect'),
noiseButton: document.getElementById('noiseButton'),
muteButton: document.getElementById('muteButton'),
muteLabel: document.getElementById('muteLabel'),
micGain: document.getElementById('micGain'),
micGainValue: document.getElementById('micGainValue'),
leave: document.getElementById('leaveButton')
};
function post(message) {
if (state.disposed) return;
window.chrome?.webview?.postMessage(message);
}
function reportStatus(message) {
ui.status.textContent = message;
post({ type: 'status', message });
}
function reportError(error) {
const message = error instanceof Error ? error.message : String(error);
ui.status.textContent = message;
post({ type: 'error', message });
}
window.chrome.webview.addEventListener('message', event => {
hostMessageChain = hostMessageChain
.then(() => handleHostMessage(event.data))
.catch(reportError);
});
async function handleHostMessage(message) {
if (!message || typeof message.type !== 'string') return;
switch (message.type) {
case 'host.init':
await initialize(message);
break;
case 'call.participants':
await reconcileParticipants(message.participants || []);
break;
case 'call.participant-left':
removePeer(message.userId);
break;
case 'webrtc.signal':
await handleRemoteSignal(message.signalType, message.fromUserId, message.signal || {});
break;
case 'call.relay-audio':
await handleRelayAudio(message.fromUserId, message.sequence, message.sampleRate, message.pcmBase64);
break;
case 'realtime.state':
if (message.state !== 'Connected') reportStatus('信令连接暂时中断,正在恢复…');
break;
case 'host.dispose':
await disposeCall();
break;
}
}
async function initialize(config) {
if (state.initialized || state.disposed) return;
state.initialized = true;
state.config = config;
state.noiseSuppression = config.startWithNoiseSuppression !== false;
ui.noiseButton.classList.toggle('active', state.noiseSuppression);
ui.noiseButton.setAttribute('aria-pressed', String(state.noiseSuppression));
ui.meshBadge.textContent = `小群通话 · 最多 ${config.maximumParticipants} 人`;
ui.turnBadge.textContent = 'WebRTC 优先 · 自动中继待命';
ui.turnBadge.classList.remove('warning');
const supported = navigator.mediaDevices.getSupportedConstraints();
ui.noiseBadge.textContent = supported.voiceIsolation
? 'Voice Isolation 可用'
: 'WebRTC AEC / NS / AGC';
ui.noiseBadge.classList.remove('muted');
renderSelfCard();
await acquireMicrophone();
await refreshMicrophoneList();
reportStatus('麦克风已就绪,等待其他成员加入');
state.metricsTimer = setInterval(() => void updateNetworkMetrics(), 2000);
}
function buildAudioConstraints() {
const supported = navigator.mediaDevices.getSupportedConstraints();
const audio = {
echoCancellation: true,
noiseSuppression: state.noiseSuppression,
autoGainControl: true,
channelCount: 1,
sampleRate: 48000,
sampleSize: 16
};
if (supported.voiceIsolation) audio.voiceIsolation = state.noiseSuppression;
if (state.currentDeviceId) audio.deviceId = { exact: state.currentDeviceId };
return { audio, video: false };
}
async function acquireMicrophone() {
if (state.disposed) return;
reportStatus('正在申请麦克风权限…');
const nextRawStream = await navigator.mediaDevices.getUserMedia(buildAudioConstraints());
if (!state.audioContext) state.audioContext = new AudioContext({ latencyHint: 'interactive', sampleRate: 48000 });
if (state.audioContext.state === 'suspended') await state.audioContext.resume();
const oldRawStream = state.rawStream;
const oldProcessedStream = state.processedStream;
if (state.sourceNode) state.sourceNode.disconnect();
if (state.limiterNode) state.limiterNode.disconnect();
const source = state.audioContext.createMediaStreamSource(nextRawStream);
const highPass = state.audioContext.createBiquadFilter();
highPass.type = 'highpass';
highPass.frequency.value = 80;
highPass.Q.value = .7;
const lowPass = state.audioContext.createBiquadFilter();
lowPass.type = 'lowpass';
lowPass.frequency.value = 12000;
lowPass.Q.value = .5;
const gain = state.audioContext.createGain();
const limiter = state.audioContext.createDynamicsCompressor();
limiter.threshold.value = -3;
limiter.knee.value = 0;
limiter.ratio.value = 20;
limiter.attack.value = .003;
limiter.release.value = .08;
const destination = state.audioContext.createMediaStreamDestination();
source.connect(highPass).connect(lowPass).connect(gain).connect(limiter).connect(destination);
await ensureRelayCapture();
limiter.connect(state.relayNode);
state.rawStream = nextRawStream;
state.processedStream = destination.stream;
state.sourceNode = source;
state.micGainNode = gain;
state.limiterNode = limiter;
applyMicrophoneGain();
const nextTrack = destination.stream.getAudioTracks()[0];
for (const peer of state.peers.values()) {
const sender = peer.pc.getSenders().find(item => item.track?.kind === 'audio');
if (sender) await sender.replaceTrack(nextTrack);
}
oldRawStream?.getTracks().forEach(track => track.stop());
oldProcessedStream?.getTracks().forEach(track => track.stop());
}
async function ensureRelayCapture() {
if (state.relayNode) return;
await state.audioContext.audioWorklet.addModule('relay-worklet.js');
const relayNode = new AudioWorkletNode(state.audioContext, 'relay-pcm-processor');
const relaySinkNode = state.audioContext.createGain();
relaySinkNode.gain.value = 0;
relayNode.connect(relaySinkNode).connect(state.audioContext.destination);
relayNode.port.onmessage = event => {
if (!state.relayCaptureActive || state.disposed || !(event.data instanceof ArrayBuffer)) return;
const bytes = new Uint8Array(event.data);
if (bytes.byteLength !== 640) return;
post({
type: 'relay-audio',
callId: state.config.callId,
sequence: state.relaySequence++,
sampleRate: 16000,
pcmBase64: bytesToBase64(bytes)
});
};
state.relayNode = relayNode;
state.relaySinkNode = relaySinkNode;
relayNode.port.postMessage({ active: false });
}
function bytesToBase64(bytes) {
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
const chunk = bytes.subarray(index, Math.min(index + 0x8000, bytes.length));
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function refreshMicrophoneList() {
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(item => item.kind === 'audioinput');
const activeDevice = state.rawStream?.getAudioTracks()[0]?.getSettings().deviceId || state.currentDeviceId;
ui.microphone.replaceChildren();
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `麦克风 ${index + 1}`;
option.selected = device.deviceId === activeDevice;
ui.microphone.append(option);
});
ui.microphone.disabled = devices.length === 0;
state.currentDeviceId = activeDevice || devices[0]?.deviceId || '';
}
function applyMicrophoneGain() {
const gain = state.micMuted ? 0 : Number(ui.micGain.value) / 100;
if (state.micGainNode) state.micGainNode.gain.setTargetAtTime(gain, state.audioContext.currentTime, .015);
const track = state.processedStream?.getAudioTracks()[0];
if (track) track.enabled = !state.micMuted;
ui.micGainValue.value = `${ui.micGain.value}%`;
}
function renderSelfCard() {
const card = createParticipantCard(state.config.selfUserId, state.config.selfDisplayName, true);
card.querySelector('.identity span').textContent = '本机 · 降噪与自动中继就绪';
card.querySelector('.connection-dot').classList.add('connected');
ui.grid.prepend(card);
updateParticipantCount();
}
function createParticipantCard(userId, displayName, self = false) {
const existing = document.getElementById(`peer-${safeDomId(userId)}`);
if (existing) return existing;
const card = ui.template.content.firstElementChild.cloneNode(true);
card.id = `peer-${safeDomId(userId)}`;
card.dataset.userId = userId;
card.classList.toggle('self', self);
card.querySelector('.avatar').textContent = initials(displayName);
card.querySelector('.identity strong').textContent = self ? `${displayName}(我)` : displayName;
const controls = card.querySelector('.volume-row');
if (self) controls.remove();
ui.grid.append(card);
return card;
}
async function reconcileParticipants(participants) {
if (state.disposed) return;
if (!state.config || !state.processedStream)
throw new Error('通话音频尚未完成初始化。');
const desired = new Set();
for (const participant of participants) {
if (!participant?.userId || participant.userId === state.config.selfUserId) continue;
desired.add(participant.userId);
state.participantNames.set(participant.userId, participant.displayName || participant.userId);
const shouldOffer = state.config.selfUserId.localeCompare(participant.userId, 'en') < 0;
await ensurePeer(participant.userId, participant.displayName || participant.userId, shouldOffer);
}
for (const userId of [...state.peers.keys()]) {
if (!desired.has(userId)) removePeer(userId);
}
updateParticipantCount();
}
async function ensurePeer(userId, displayName, initiate) {
if (state.peers.has(userId)) return state.peers.get(userId);
if (state.peers.size >= state.config.maximumParticipants - 1) throw new Error(`小群通话最多支持 ${state.config.maximumParticipants} 人。`);
const card = createParticipantCard(userId, displayName, false);
const pc = new RTCPeerConnection({
iceServers: state.config.iceServers || [],
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
iceCandidatePoolSize: 2
});
const peer = {
userId,
displayName,
pc,
card,
gainNode: null,
sourceNode: null,
pendingIce: [],
muted: false,
volume: 1,
relayActive: false,
relayFallbackTimer: null,
relaySources: new Set(),
relayNextPlayTime: 0,
lastRelaySequence: -1,
directMediaReady: false,
directPacketsReceived: 0,
directPacketProgressAt: 0
};
state.peers.set(userId, peer);
const localTrack = state.processedStream.getAudioTracks()[0];
const sender = pc.addTrack(localTrack, state.processedStream);
preferOpus(pc, sender);
pc.onicecandidate = event => {
if (event.candidate) sendSignal('webrtc.ice', userId, { candidate: event.candidate.toJSON() });
};
pc.ontrack = event => attachRemoteAudio(peer, event.streams[0] || new MediaStream([event.track]));
pc.onconnectionstatechange = () => updatePeerConnectionState(peer);
pc.oniceconnectionstatechange = () => updatePeerConnectionState(peer);
bindPeerControls(peer);
armRelayFallback(peer, 3500);
updateParticipantCount();
updateTransportBadge();
if (initiate) {
await pc.setLocalDescription(await pc.createOffer({ offerToReceiveAudio: true }));
sendSignal('webrtc.offer', userId, { sdp: pc.localDescription });
}
return peer;
}
function preferOpus(pc, sender) {
const transceiver = pc.getTransceivers().find(item => item.sender === sender);
const capabilities = RTCRtpSender.getCapabilities?.('audio');
if (!transceiver?.setCodecPreferences || !capabilities?.codecs) return;
const codecs = [...capabilities.codecs].sort((left, right) => {
const leftOpus = left.mimeType.toLowerCase() === 'audio/opus' ? 1 : 0;
const rightOpus = right.mimeType.toLowerCase() === 'audio/opus' ? 1 : 0;
return rightOpus - leftOpus;
});
transceiver.setCodecPreferences(codecs);
}
function attachRemoteAudio(peer, stream) {
if (peer.sourceNode) peer.sourceNode.disconnect();
const source = state.audioContext.createMediaStreamSource(stream);
source.connect(ensurePeerGain(peer));
peer.sourceNode = source;
peer.card.querySelector('.identity span').textContent = '语音轨道已接收';
}
function ensurePeerGain(peer) {
if (peer.gainNode) return peer.gainNode;
const gain = state.audioContext.createGain();
gain.gain.value = peer.muted ? 0 : peer.volume;
gain.connect(state.audioContext.destination);
peer.gainNode = gain;
return gain;
}
function bindPeerControls(peer) {
const mute = peer.card.querySelector('.peer-mute');
const volume = peer.card.querySelector('.peer-volume');
const output = peer.card.querySelector('output');
mute.addEventListener('click', () => {
peer.muted = !peer.muted;
mute.setAttribute('aria-pressed', String(peer.muted));
mute.textContent = peer.muted ? '已静音' : '静音';
applyPeerGain(peer);
});
volume.addEventListener('input', () => {
peer.volume = Number(volume.value) / 100;
output.value = `${volume.value}%`;
applyPeerGain(peer);
});
}
function applyPeerGain(peer) {
if (peer.gainNode) peer.gainNode.gain.setTargetAtTime(peer.muted ? 0 : peer.volume, state.audioContext.currentTime, .015);
}
async function handleRemoteSignal(signalType, fromUserId, signal) {
if (state.disposed) return;
const displayName = state.participantNames.get(fromUserId) || fromUserId;
const peer = await ensurePeer(fromUserId, displayName, false);
if (signalType === 'webrtc.offer') {
await peer.pc.setRemoteDescription(signal.sdp);
await flushPendingIce(peer);
await peer.pc.setLocalDescription(await peer.pc.createAnswer());
sendSignal('webrtc.answer', fromUserId, { sdp: peer.pc.localDescription });
} else if (signalType === 'webrtc.answer') {
await peer.pc.setRemoteDescription(signal.sdp);
await flushPendingIce(peer);
} else if (signalType === 'webrtc.ice' && signal.candidate) {
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.candidate);
else peer.pendingIce.push(signal.candidate);
}
}
async function flushPendingIce(peer) {
while (peer.pendingIce.length) await peer.pc.addIceCandidate(peer.pendingIce.shift());
}
function sendSignal(signalType, targetUserId, detail) {
const payload = { callId: state.config.callId, targetUserId, ...detail };
post({ type: 'signal', callId: state.config.callId, signalType, targetUserId, payload });
}
async function handleRelayAudio(fromUserId, sequence, sampleRate, pcmBase64) {
if (state.disposed || !fromUserId || fromUserId === state.config.selfUserId || sampleRate !== 16000 ||
!Number.isSafeInteger(sequence) || sequence < 0 || typeof pcmBase64 !== 'string') return;
const displayName = state.participantNames.get(fromUserId) || fromUserId;
const peer = await ensurePeer(fromUserId, displayName, false);
if (peer.directMediaReady || sequence <= peer.lastRelaySequence) return;
let bytes;
try {
const binary = atob(pcmBase64);
if (binary.length !== 640) return;
bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
} catch {
return;
}
peer.lastRelaySequence = sequence;
enablePeerRelay(peer);
const buffer = state.audioContext.createBuffer(1, 320, sampleRate);
const samples = buffer.getChannelData(0);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let index = 0; index < samples.length; index++)
samples[index] = view.getInt16(index * 2, true) / 32768;
const source = state.audioContext.createBufferSource();
source.buffer = buffer;
source.connect(ensurePeerGain(peer));
const now = state.audioContext.currentTime;
if (peer.relayNextPlayTime < now || peer.relayNextPlayTime > now + .35)
peer.relayNextPlayTime = now + .06;
source.start(peer.relayNextPlayTime);
peer.relayNextPlayTime += buffer.duration;
peer.relaySources.add(source);
source.onended = () => peer.relaySources.delete(source);
const dot = peer.card.querySelector('.connection-dot');
dot.classList.add('connected');
dot.classList.remove('failed');
peer.card.querySelector('.identity span').textContent = '已连接 · WSS 自动中继';
}
function armRelayFallback(peer, delay) {
if (peer.relayFallbackTimer != null || peer.relayActive || peer.directMediaReady) return;
peer.relayFallbackTimer = setTimeout(() => {
peer.relayFallbackTimer = null;
if (!peer.directMediaReady) enablePeerRelay(peer);
}, delay);
}
function enablePeerRelay(peer) {
if (peer.directMediaReady) return;
if (!peer.relayActive) {
peer.relayActive = true;
peer.card.querySelector('.identity span').textContent = '正在启用自动中继';
}
setRelayCaptureState();
updateTransportBadge();
}
function disablePeerRelay(peer) {
if (peer.relayFallbackTimer != null) {
clearTimeout(peer.relayFallbackTimer);
peer.relayFallbackTimer = null;
}
peer.relayActive = false;
peer.relayNextPlayTime = 0;
for (const source of peer.relaySources) {
try { source.stop(); } catch { }
}
peer.relaySources.clear();
setRelayCaptureState();
updateTransportBadge();
}
function setRelayCaptureState() {
const active = [...state.peers.values()].some(peer => peer.relayActive);
if (state.relayCaptureActive === active) return;
state.relayCaptureActive = active;
state.relayNode?.port.postMessage({ active });
}
function updateTransportBadge() {
const peers = [...state.peers.values()];
const relaying = peers.some(peer => peer.relayActive);
const direct = peers.length > 0 && peers.every(peer => peer.directMediaReady);
ui.turnBadge.textContent = relaying
? 'WSS 自动中继'
: direct
? 'WebRTC 直连'
: 'WebRTC 优先 · 自动中继待命';
ui.turnBadge.classList.toggle('relay', relaying);
ui.turnBadge.classList.remove('warning');
}
function updatePeerConnectionState(peer) {
const connectionState = peer.pc.connectionState;
const dot = peer.card.querySelector('.connection-dot');
dot.classList.toggle('connected', connectionState === 'connected');
dot.classList.toggle('failed', connectionState === 'failed' || connectionState === 'closed');
if (connectionState === 'connected') {
if (peer.directMediaReady) {
disablePeerRelay(peer);
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
} else {
armRelayFallback(peer, 2500);
if (!peer.relayActive) peer.card.querySelector('.identity span').textContent = '已连接 · 正在验证音频';
}
} else if (connectionState === 'failed') {
enablePeerRelay(peer);
} else if (connectionState === 'disconnected') {
armRelayFallback(peer, 800);
} else if (connectionState === 'new' || connectionState === 'connecting') {
armRelayFallback(peer, 3500);
}
if (!peer.relayActive && connectionState !== 'connected') {
peer.card.querySelector('.identity span').textContent = ({
new: '等待协商', connecting: '正在建立低延迟链路', connected: '已连接 · WebRTC/Opus',
disconnected: '连接暂时中断', failed: '正在切换自动中继', closed: '已离开'
})[connectionState] || connectionState;
}
updateTransportBadge();
}
function removePeer(userId) {
const peer = state.peers.get(userId);
if (!peer) return;
disablePeerRelay(peer);
peer.sourceNode?.disconnect();
peer.gainNode?.disconnect();
peer.pc.ontrack = null;
peer.pc.onicecandidate = null;
peer.pc.close();
peer.card.remove();
state.peers.delete(userId);
state.participantNames.delete(userId);
updateParticipantCount();
updateTransportBadge();
}
async function updateNetworkMetrics() {
if (state.disposed) return;
let maxRtt = null;
let maxJitter = null;
let lost = 0;
let received = 0;
for (const peer of state.peers.values()) {
const stats = await peer.pc.getStats();
let packetsReceived = 0;
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.state === 'succeeded' && Number.isFinite(report.currentRoundTripTime))
maxRtt = Math.max(maxRtt || 0, report.currentRoundTripTime);
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
packetsReceived += Math.max(0, report.packetsReceived || 0);
if (Number.isFinite(report.jitter)) maxJitter = Math.max(maxJitter || 0, report.jitter);
lost += Math.max(0, report.packetsLost || 0);
received += Math.max(0, report.packetsReceived || 0);
}
});
if (packetsReceived > peer.directPacketsReceived) {
peer.directPacketsReceived = packetsReceived;
peer.directPacketProgressAt = performance.now();
if (!peer.directMediaReady) {
peer.directMediaReady = true;
disablePeerRelay(peer);
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
}
} else if (peer.directMediaReady && performance.now() - peer.directPacketProgressAt > 5000) {
peer.directMediaReady = false;
enablePeerRelay(peer);
} else if (!peer.directMediaReady) {
armRelayFallback(peer, 1200);
}
}
ui.roundTrip.textContent = maxRtt == null ? '—' : `${Math.round(maxRtt * 1000)} ms`;
ui.jitter.textContent = maxJitter == null ? '—' : `${Math.round(maxJitter * 1000)} ms`;
ui.packetLoss.textContent = received + lost === 0 ? '—' : `${(lost / (received + lost) * 100).toFixed(1)}%`;
}
function updateParticipantCount() {
ui.participantCount.textContent = String(1 + state.peers.size);
}
function initials(name) {
const trimmed = String(name || '?').trim();
return [...trimmed].slice(0, 2).join('').toUpperCase();
}
function safeDomId(value) {
return [...String(value)].map(character => /[a-zA-Z0-9_-]/.test(character) ? character : `_${character.codePointAt(0).toString(16)}`).join('');
}
ui.microphone.addEventListener('change', () => {
if (state.disposed) return;
state.currentDeviceId = ui.microphone.value;
void acquireMicrophone().then(refreshMicrophoneList).then(() => reportStatus('已切换麦克风')).catch(reportError);
});
ui.noiseButton.addEventListener('click', () => {
if (state.disposed) return;
state.noiseSuppression = !state.noiseSuppression;
ui.noiseButton.classList.toggle('active', state.noiseSuppression);
ui.noiseButton.setAttribute('aria-pressed', String(state.noiseSuppression));
void acquireMicrophone().then(() => reportStatus(state.noiseSuppression ? 'WebRTC 降噪已开启' : 'WebRTC 降噪已关闭')).catch(reportError);
});
ui.muteButton.addEventListener('click', () => {
if (state.disposed) return;
state.micMuted = !state.micMuted;
ui.muteButton.classList.toggle('active', !state.micMuted);
ui.muteButton.setAttribute('aria-pressed', String(state.micMuted));
ui.muteLabel.textContent = state.micMuted ? '麦克风静音' : '麦克风开启';
applyMicrophoneGain();
});
ui.micGain.addEventListener('input', applyMicrophoneGain);
ui.leave.addEventListener('click', () => {
if (state.stopping || state.disposed) return;
state.stopping = true;
post({ type: 'leave', callId: state.config?.callId });
});
document.addEventListener('pointerdown', () => {
if (state.audioContext?.state === 'suspended') void state.audioContext.resume();
}, { once: true });
async function disposeCall() {
if (state.disposed) return;
state.disposed = true;
state.stopping = true;
if (state.metricsTimer != null) {
clearInterval(state.metricsTimer);
state.metricsTimer = null;
}
for (const userId of [...state.peers.keys()]) removePeer(userId);
state.sourceNode?.disconnect();
state.micGainNode?.disconnect();
state.limiterNode?.disconnect();
state.relayNode?.disconnect();
state.relaySinkNode?.disconnect();
state.rawStream?.getTracks().forEach(track => track.stop());
state.processedStream?.getTracks().forEach(track => track.stop());
state.rawStream = null;
state.processedStream = null;
state.sourceNode = null;
state.micGainNode = null;
state.limiterNode = null;
state.relayNode = null;
state.relaySinkNode = null;
const context = state.audioContext;
state.audioContext = null;
if (context && context.state !== 'closed') {
try { await context.close(); } catch { }
}
}
window.addEventListener('beforeunload', () => {
void disposeCall();
});
post({ type: 'ready' });
'use strict';
const state = {
config: null,
audioContext: null,
rawStream: null,
processedStream: null,
sourceNode: null,
micGainNode: null,
limiterNode: null,
relayNode: null,
relaySinkNode: null,
relaySequence: 0,
relayCaptureActive: false,
peers: new Map(),
participantNames: new Map(),
micMuted: false,
noiseSuppression: true,
currentDeviceId: '',
initialized: false,
stopping: false,
disposed: false,
metricsTimer: null
};
let hostMessageChain = Promise.resolve();
const ui = {
status: document.getElementById('statusText'),
meshBadge: document.getElementById('meshBadge'),
noiseBadge: document.getElementById('noiseBadge'),
turnBadge: document.getElementById('turnBadge'),
participantCount: document.getElementById('participantCount'),
roundTrip: document.getElementById('roundTrip'),
jitter: document.getElementById('jitter'),
packetLoss: document.getElementById('packetLoss'),
grid: document.getElementById('participantGrid'),
template: document.getElementById('participantTemplate'),
microphone: document.getElementById('microphoneSelect'),
noiseButton: document.getElementById('noiseButton'),
muteButton: document.getElementById('muteButton'),
muteLabel: document.getElementById('muteLabel'),
micGain: document.getElementById('micGain'),
micGainValue: document.getElementById('micGainValue'),
leave: document.getElementById('leaveButton')
};
function post(message) {
if (state.disposed) return;
window.chrome?.webview?.postMessage(message);
}
function reportStatus(message) {
ui.status.textContent = message;
post({ type: 'status', message });
}
function reportError(error) {
const message = error instanceof Error ? error.message : String(error);
ui.status.textContent = message;
post({ type: 'error', message });
}
window.chrome.webview.addEventListener('message', event => {
hostMessageChain = hostMessageChain
.then(() => handleHostMessage(event.data))
.catch(reportError);
});
async function handleHostMessage(message) {
if (!message || typeof message.type !== 'string') return;
switch (message.type) {
case 'host.init':
await initialize(message);
break;
case 'call.participants':
await reconcileParticipants(message.participants || []);
break;
case 'call.participant-left':
removePeer(message.userId);
break;
case 'webrtc.signal':
await handleRemoteSignal(message.signalType, message.fromUserId, message.signal || {});
break;
case 'call.relay-audio':
await handleRelayAudio(message.fromUserId, message.sequence, message.sampleRate, message.pcmBase64);
break;
case 'realtime.state':
if (message.state !== 'Connected') reportStatus('信令连接暂时中断,正在恢复…');
break;
case 'host.dispose':
await disposeCall();
break;
}
}
async function initialize(config) {
if (state.initialized || state.disposed) return;
state.initialized = true;
state.config = config;
state.noiseSuppression = config.startWithNoiseSuppression !== false;
ui.noiseButton.classList.toggle('active', state.noiseSuppression);
ui.noiseButton.setAttribute('aria-pressed', String(state.noiseSuppression));
ui.meshBadge.textContent = `小群通话 · 最多 ${config.maximumParticipants} 人`;
ui.turnBadge.textContent = 'WebRTC 优先 · 自动中继待命';
ui.turnBadge.classList.remove('warning');
const supported = navigator.mediaDevices.getSupportedConstraints();
ui.noiseBadge.textContent = supported.voiceIsolation
? 'Voice Isolation 可用'
: 'WebRTC AEC / NS / AGC';
ui.noiseBadge.classList.remove('muted');
renderSelfCard();
await acquireMicrophone();
await refreshMicrophoneList();
reportStatus('麦克风已就绪,等待其他成员加入');
state.metricsTimer = setInterval(() => void updateNetworkMetrics(), 2000);
}
function buildAudioConstraints() {
const supported = navigator.mediaDevices.getSupportedConstraints();
const audio = {
echoCancellation: true,
noiseSuppression: state.noiseSuppression,
autoGainControl: true,
channelCount: 1,
sampleRate: 48000,
sampleSize: 16
};
if (supported.voiceIsolation) audio.voiceIsolation = state.noiseSuppression;
if (state.currentDeviceId) audio.deviceId = { exact: state.currentDeviceId };
return { audio, video: false };
}
async function acquireMicrophone() {
if (state.disposed) return;
reportStatus('正在申请麦克风权限…');
const nextRawStream = await navigator.mediaDevices.getUserMedia(buildAudioConstraints());
if (!state.audioContext) state.audioContext = new AudioContext({ latencyHint: 'interactive', sampleRate: 48000 });
if (state.audioContext.state === 'suspended') await state.audioContext.resume();
const oldRawStream = state.rawStream;
const oldProcessedStream = state.processedStream;
if (state.sourceNode) state.sourceNode.disconnect();
if (state.limiterNode) state.limiterNode.disconnect();
const source = state.audioContext.createMediaStreamSource(nextRawStream);
const highPass = state.audioContext.createBiquadFilter();
highPass.type = 'highpass';
highPass.frequency.value = 80;
highPass.Q.value = .7;
const lowPass = state.audioContext.createBiquadFilter();
lowPass.type = 'lowpass';
lowPass.frequency.value = 12000;
lowPass.Q.value = .5;
const gain = state.audioContext.createGain();
const limiter = state.audioContext.createDynamicsCompressor();
limiter.threshold.value = -3;
limiter.knee.value = 0;
limiter.ratio.value = 20;
limiter.attack.value = .003;
limiter.release.value = .08;
const destination = state.audioContext.createMediaStreamDestination();
source.connect(highPass).connect(lowPass).connect(gain).connect(limiter).connect(destination);
await ensureRelayCapture();
limiter.connect(state.relayNode);
state.rawStream = nextRawStream;
state.processedStream = destination.stream;
state.sourceNode = source;
state.micGainNode = gain;
state.limiterNode = limiter;
applyMicrophoneGain();
const nextTrack = destination.stream.getAudioTracks()[0];
for (const peer of state.peers.values()) {
const sender = peer.pc.getSenders().find(item => item.track?.kind === 'audio');
if (sender) await sender.replaceTrack(nextTrack);
}
oldRawStream?.getTracks().forEach(track => track.stop());
oldProcessedStream?.getTracks().forEach(track => track.stop());
}
async function ensureRelayCapture() {
if (state.relayNode) return;
await state.audioContext.audioWorklet.addModule('relay-worklet.js');
const relayNode = new AudioWorkletNode(state.audioContext, 'relay-pcm-processor');
const relaySinkNode = state.audioContext.createGain();
relaySinkNode.gain.value = 0;
relayNode.connect(relaySinkNode).connect(state.audioContext.destination);
relayNode.port.onmessage = event => {
if (!state.relayCaptureActive || state.disposed || !(event.data instanceof ArrayBuffer)) return;
const bytes = new Uint8Array(event.data);
if (bytes.byteLength !== 640) return;
post({
type: 'relay-audio',
callId: state.config.callId,
sequence: state.relaySequence++,
sampleRate: 16000,
pcmBase64: bytesToBase64(bytes)
});
};
state.relayNode = relayNode;
state.relaySinkNode = relaySinkNode;
relayNode.port.postMessage({ active: false });
}
function bytesToBase64(bytes) {
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
const chunk = bytes.subarray(index, Math.min(index + 0x8000, bytes.length));
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function refreshMicrophoneList() {
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(item => item.kind === 'audioinput');
const activeDevice = state.rawStream?.getAudioTracks()[0]?.getSettings().deviceId || state.currentDeviceId;
ui.microphone.replaceChildren();
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `麦克风 ${index + 1}`;
option.selected = device.deviceId === activeDevice;
ui.microphone.append(option);
});
ui.microphone.disabled = devices.length === 0;
state.currentDeviceId = activeDevice || devices[0]?.deviceId || '';
}
function applyMicrophoneGain() {
const gain = state.micMuted ? 0 : Number(ui.micGain.value) / 100;
if (state.micGainNode) state.micGainNode.gain.setTargetAtTime(gain, state.audioContext.currentTime, .015);
const track = state.processedStream?.getAudioTracks()[0];
if (track) track.enabled = !state.micMuted;
ui.micGainValue.value = `${ui.micGain.value}%`;
}
function renderSelfCard() {
const card = createParticipantCard(state.config.selfUserId, state.config.selfDisplayName, true);
card.querySelector('.identity span').textContent = '本机 · 降噪与自动中继就绪';
card.querySelector('.connection-dot').classList.add('connected');
ui.grid.prepend(card);
updateParticipantCount();
}
function createParticipantCard(userId, displayName, self = false) {
const existing = document.getElementById(`peer-${safeDomId(userId)}`);
if (existing) return existing;
const card = ui.template.content.firstElementChild.cloneNode(true);
card.id = `peer-${safeDomId(userId)}`;
card.dataset.userId = userId;
card.classList.toggle('self', self);
card.querySelector('.avatar').textContent = initials(displayName);
card.querySelector('.identity strong').textContent = self ? `${displayName}(我)` : displayName;
const controls = card.querySelector('.volume-row');
if (self) controls.remove();
ui.grid.append(card);
return card;
}
async function reconcileParticipants(participants) {
if (state.disposed) return;
if (!state.config || !state.processedStream)
throw new Error('通话音频尚未完成初始化。');
const desired = new Set();
for (const participant of participants) {
if (!participant?.userId || participant.userId === state.config.selfUserId) continue;
desired.add(participant.userId);
state.participantNames.set(participant.userId, participant.displayName || participant.userId);
const shouldOffer = state.config.selfUserId.localeCompare(participant.userId, 'en') < 0;
await ensurePeer(participant.userId, participant.displayName || participant.userId, shouldOffer);
}
for (const userId of [...state.peers.keys()]) {
if (!desired.has(userId)) removePeer(userId);
}
updateParticipantCount();
}
async function ensurePeer(userId, displayName, initiate) {
if (state.peers.has(userId)) return state.peers.get(userId);
if (state.peers.size >= state.config.maximumParticipants - 1) throw new Error(`小群通话最多支持 ${state.config.maximumParticipants} 人。`);
const card = createParticipantCard(userId, displayName, false);
const pc = new RTCPeerConnection({
iceServers: state.config.iceServers || [],
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
iceCandidatePoolSize: 2
});
const peer = {
userId,
displayName,
pc,
card,
gainNode: null,
sourceNode: null,
pendingIce: [],
muted: false,
volume: 1,
relayActive: false,
relayFallbackTimer: null,
relaySources: new Set(),
relayNextPlayTime: 0,
lastRelaySequence: -1,
directMediaReady: false,
directPacketsReceived: 0,
directPacketProgressAt: 0
};
state.peers.set(userId, peer);
const localTrack = state.processedStream.getAudioTracks()[0];
const sender = pc.addTrack(localTrack, state.processedStream);
preferOpus(pc, sender);
pc.onicecandidate = event => {
if (event.candidate) sendSignal('webrtc.ice', userId, { candidate: event.candidate.toJSON() });
};
pc.ontrack = event => attachRemoteAudio(peer, event.streams[0] || new MediaStream([event.track]));
pc.onconnectionstatechange = () => updatePeerConnectionState(peer);
pc.oniceconnectionstatechange = () => updatePeerConnectionState(peer);
bindPeerControls(peer);
armRelayFallback(peer, 3500);
updateParticipantCount();
updateTransportBadge();
if (initiate) {
await pc.setLocalDescription(await pc.createOffer({ offerToReceiveAudio: true }));
sendSignal('webrtc.offer', userId, { sdp: pc.localDescription });
}
return peer;
}
function preferOpus(pc, sender) {
const transceiver = pc.getTransceivers().find(item => item.sender === sender);
const capabilities = RTCRtpSender.getCapabilities?.('audio');
if (!transceiver?.setCodecPreferences || !capabilities?.codecs) return;
const codecs = [...capabilities.codecs].sort((left, right) => {
const leftOpus = left.mimeType.toLowerCase() === 'audio/opus' ? 1 : 0;
const rightOpus = right.mimeType.toLowerCase() === 'audio/opus' ? 1 : 0;
return rightOpus - leftOpus;
});
transceiver.setCodecPreferences(codecs);
}
function attachRemoteAudio(peer, stream) {
if (peer.sourceNode) peer.sourceNode.disconnect();
const source = state.audioContext.createMediaStreamSource(stream);
source.connect(ensurePeerGain(peer));
peer.sourceNode = source;
peer.card.querySelector('.identity span').textContent = '语音轨道已接收';
}
function ensurePeerGain(peer) {
if (peer.gainNode) return peer.gainNode;
const gain = state.audioContext.createGain();
gain.gain.value = peer.muted ? 0 : peer.volume;
gain.connect(state.audioContext.destination);
peer.gainNode = gain;
return gain;
}
function bindPeerControls(peer) {
const mute = peer.card.querySelector('.peer-mute');
const volume = peer.card.querySelector('.peer-volume');
const output = peer.card.querySelector('output');
mute.addEventListener('click', () => {
peer.muted = !peer.muted;
mute.setAttribute('aria-pressed', String(peer.muted));
mute.textContent = peer.muted ? '已静音' : '静音';
applyPeerGain(peer);
});
volume.addEventListener('input', () => {
peer.volume = Number(volume.value) / 100;
output.value = `${volume.value}%`;
applyPeerGain(peer);
});
}
function applyPeerGain(peer) {
if (peer.gainNode) peer.gainNode.gain.setTargetAtTime(peer.muted ? 0 : peer.volume, state.audioContext.currentTime, .015);
}
async function handleRemoteSignal(signalType, fromUserId, signal) {
if (state.disposed) return;
const displayName = state.participantNames.get(fromUserId) || fromUserId;
const peer = await ensurePeer(fromUserId, displayName, false);
if (signalType === 'webrtc.offer') {
await peer.pc.setRemoteDescription(signal.sdp);
await flushPendingIce(peer);
await peer.pc.setLocalDescription(await peer.pc.createAnswer());
sendSignal('webrtc.answer', fromUserId, { sdp: peer.pc.localDescription });
} else if (signalType === 'webrtc.answer') {
await peer.pc.setRemoteDescription(signal.sdp);
await flushPendingIce(peer);
} else if (signalType === 'webrtc.ice' && signal.candidate) {
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.candidate);
else peer.pendingIce.push(signal.candidate);
}
}
async function flushPendingIce(peer) {
while (peer.pendingIce.length) await peer.pc.addIceCandidate(peer.pendingIce.shift());
}
function sendSignal(signalType, targetUserId, detail) {
const payload = { callId: state.config.callId, targetUserId, ...detail };
post({ type: 'signal', callId: state.config.callId, signalType, targetUserId, payload });
}
async function handleRelayAudio(fromUserId, sequence, sampleRate, pcmBase64) {
if (state.disposed || !fromUserId || fromUserId === state.config.selfUserId || sampleRate !== 16000 ||
!Number.isSafeInteger(sequence) || sequence < 0 || typeof pcmBase64 !== 'string') return;
const displayName = state.participantNames.get(fromUserId) || fromUserId;
const peer = await ensurePeer(fromUserId, displayName, false);
if (peer.directMediaReady || sequence <= peer.lastRelaySequence) return;
let bytes;
try {
const binary = atob(pcmBase64);
if (binary.length !== 640) return;
bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
} catch {
return;
}
peer.lastRelaySequence = sequence;
enablePeerRelay(peer);
const buffer = state.audioContext.createBuffer(1, 320, sampleRate);
const samples = buffer.getChannelData(0);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let index = 0; index < samples.length; index++)
samples[index] = view.getInt16(index * 2, true) / 32768;
const source = state.audioContext.createBufferSource();
source.buffer = buffer;
source.connect(ensurePeerGain(peer));
const now = state.audioContext.currentTime;
if (peer.relayNextPlayTime < now || peer.relayNextPlayTime > now + .35)
peer.relayNextPlayTime = now + .06;
source.start(peer.relayNextPlayTime);
peer.relayNextPlayTime += buffer.duration;
peer.relaySources.add(source);
source.onended = () => peer.relaySources.delete(source);
const dot = peer.card.querySelector('.connection-dot');
dot.classList.add('connected');
dot.classList.remove('failed');
peer.card.querySelector('.identity span').textContent = '已连接 · WSS 自动中继';
}
function armRelayFallback(peer, delay) {
if (peer.relayFallbackTimer != null || peer.relayActive || peer.directMediaReady) return;
peer.relayFallbackTimer = setTimeout(() => {
peer.relayFallbackTimer = null;
if (!peer.directMediaReady) enablePeerRelay(peer);
}, delay);
}
function enablePeerRelay(peer) {
if (peer.directMediaReady) return;
if (!peer.relayActive) {
peer.relayActive = true;
peer.card.querySelector('.identity span').textContent = '正在启用自动中继';
}
setRelayCaptureState();
updateTransportBadge();
}
function disablePeerRelay(peer) {
if (peer.relayFallbackTimer != null) {
clearTimeout(peer.relayFallbackTimer);
peer.relayFallbackTimer = null;
}
peer.relayActive = false;
peer.relayNextPlayTime = 0;
for (const source of peer.relaySources) {
try { source.stop(); } catch { }
}
peer.relaySources.clear();
setRelayCaptureState();
updateTransportBadge();
}
function setRelayCaptureState() {
const active = [...state.peers.values()].some(peer => peer.relayActive);
if (state.relayCaptureActive === active) return;
state.relayCaptureActive = active;
state.relayNode?.port.postMessage({ active });
}
function updateTransportBadge() {
const peers = [...state.peers.values()];
const relaying = peers.some(peer => peer.relayActive);
const direct = peers.length > 0 && peers.every(peer => peer.directMediaReady);
ui.turnBadge.textContent = relaying
? 'WSS 自动中继'
: direct
? 'WebRTC 直连'
: 'WebRTC 优先 · 自动中继待命';
ui.turnBadge.classList.toggle('relay', relaying);
ui.turnBadge.classList.remove('warning');
}
function updatePeerConnectionState(peer) {
const connectionState = peer.pc.connectionState;
const dot = peer.card.querySelector('.connection-dot');
dot.classList.toggle('connected', connectionState === 'connected');
dot.classList.toggle('failed', connectionState === 'failed' || connectionState === 'closed');
if (connectionState === 'connected') {
if (peer.directMediaReady) {
disablePeerRelay(peer);
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
} else {
armRelayFallback(peer, 2500);
if (!peer.relayActive) peer.card.querySelector('.identity span').textContent = '已连接 · 正在验证音频';
}
} else if (connectionState === 'failed') {
enablePeerRelay(peer);
} else if (connectionState === 'disconnected') {
armRelayFallback(peer, 800);
} else if (connectionState === 'new' || connectionState === 'connecting') {
armRelayFallback(peer, 3500);
}
if (!peer.relayActive && connectionState !== 'connected') {
peer.card.querySelector('.identity span').textContent = ({
new: '等待协商', connecting: '正在建立低延迟链路', connected: '已连接 · WebRTC/Opus',
disconnected: '连接暂时中断', failed: '正在切换自动中继', closed: '已离开'
})[connectionState] || connectionState;
}
updateTransportBadge();
}
function removePeer(userId) {
const peer = state.peers.get(userId);
if (!peer) return;
disablePeerRelay(peer);
peer.sourceNode?.disconnect();
peer.gainNode?.disconnect();
peer.pc.ontrack = null;
peer.pc.onicecandidate = null;
peer.pc.close();
peer.card.remove();
state.peers.delete(userId);
state.participantNames.delete(userId);
updateParticipantCount();
updateTransportBadge();
}
async function updateNetworkMetrics() {
if (state.disposed) return;
let maxRtt = null;
let maxJitter = null;
let lost = 0;
let received = 0;
for (const peer of state.peers.values()) {
const stats = await peer.pc.getStats();
let packetsReceived = 0;
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.state === 'succeeded' && Number.isFinite(report.currentRoundTripTime))
maxRtt = Math.max(maxRtt || 0, report.currentRoundTripTime);
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
packetsReceived += Math.max(0, report.packetsReceived || 0);
if (Number.isFinite(report.jitter)) maxJitter = Math.max(maxJitter || 0, report.jitter);
lost += Math.max(0, report.packetsLost || 0);
received += Math.max(0, report.packetsReceived || 0);
}
});
if (packetsReceived > peer.directPacketsReceived) {
peer.directPacketsReceived = packetsReceived;
peer.directPacketProgressAt = performance.now();
if (!peer.directMediaReady) {
peer.directMediaReady = true;
disablePeerRelay(peer);
peer.card.querySelector('.identity span').textContent = '已连接 · WebRTC/Opus';
}
} else if (peer.directMediaReady && performance.now() - peer.directPacketProgressAt > 5000) {
peer.directMediaReady = false;
enablePeerRelay(peer);
} else if (!peer.directMediaReady) {
armRelayFallback(peer, 1200);
}
}
ui.roundTrip.textContent = maxRtt == null ? '—' : `${Math.round(maxRtt * 1000)} ms`;
ui.jitter.textContent = maxJitter == null ? '—' : `${Math.round(maxJitter * 1000)} ms`;
ui.packetLoss.textContent = received + lost === 0 ? '—' : `${(lost / (received + lost) * 100).toFixed(1)}%`;
}
function updateParticipantCount() {
ui.participantCount.textContent = String(1 + state.peers.size);
}
function initials(name) {
const trimmed = String(name || '?').trim();
return [...trimmed].slice(0, 2).join('').toUpperCase();
}
function safeDomId(value) {
return [...String(value)].map(character => /[a-zA-Z0-9_-]/.test(character) ? character : `_${character.codePointAt(0).toString(16)}`).join('');
}
ui.microphone.addEventListener('change', () => {
if (state.disposed) return;
state.currentDeviceId = ui.microphone.value;
void acquireMicrophone().then(refreshMicrophoneList).then(() => reportStatus('已切换麦克风')).catch(reportError);
});
ui.noiseButton.addEventListener('click', () => {
if (state.disposed) return;
state.noiseSuppression = !state.noiseSuppression;
ui.noiseButton.classList.toggle('active', state.noiseSuppression);
ui.noiseButton.setAttribute('aria-pressed', String(state.noiseSuppression));
void acquireMicrophone().then(() => reportStatus(state.noiseSuppression ? 'WebRTC 降噪已开启' : 'WebRTC 降噪已关闭')).catch(reportError);
});
ui.muteButton.addEventListener('click', () => {
if (state.disposed) return;
state.micMuted = !state.micMuted;
ui.muteButton.classList.toggle('active', !state.micMuted);
ui.muteButton.setAttribute('aria-pressed', String(state.micMuted));
ui.muteLabel.textContent = state.micMuted ? '麦克风静音' : '麦克风开启';
applyMicrophoneGain();
});
ui.micGain.addEventListener('input', applyMicrophoneGain);
ui.leave.addEventListener('click', () => {
if (state.stopping || state.disposed) return;
state.stopping = true;
post({ type: 'leave', callId: state.config?.callId });
});
document.addEventListener('pointerdown', () => {
if (state.audioContext?.state === 'suspended') void state.audioContext.resume();
}, { once: true });
async function disposeCall() {
if (state.disposed) return;
state.disposed = true;
state.stopping = true;
if (state.metricsTimer != null) {
clearInterval(state.metricsTimer);
state.metricsTimer = null;
}
for (const userId of [...state.peers.keys()]) removePeer(userId);
state.sourceNode?.disconnect();
state.micGainNode?.disconnect();
state.limiterNode?.disconnect();
state.relayNode?.disconnect();
state.relaySinkNode?.disconnect();
state.rawStream?.getTracks().forEach(track => track.stop());
state.processedStream?.getTracks().forEach(track => track.stop());
state.rawStream = null;
state.processedStream = null;
state.sourceNode = null;
state.micGainNode = null;
state.limiterNode = null;
state.relayNode = null;
state.relaySinkNode = null;
const context = state.audioContext;
state.audioContext = null;
if (context && context.state !== 'closed') {
try { await context.close(); } catch { }
}
}
window.addEventListener('beforeunload', () => {
void disposeCall();
});
post({ type: 'ready' });