XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
'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' });