let currentVoiceWs = null;
let currentVoiceCallId = null;
let currentVoiceCallType = null;
let voiceWsHeartbeat = null;
let voiceActiveUsers = [];
let voiceAesKeyBytes = null;
let voiceAesCryptoKey = null;
let voiceUiContainer = null;
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
let knownVoiceUsers = new Set();
let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {};
let camControl = null, screenControl = null;
let screenReceiverNode = null;
let voiceOutputAudio = null;
let voiceLoopSendPc = null, voiceLoopRecvPc = null;
let voicePlayoutDest = null;
let voiceLoopWatchdog = null;
let voiceRemoteStream = null;
let voiceLoopRestarts = 0;
let voiceDeviceChangeHandler = null;
let voiceDeviceChangeTimer = null;
let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400;
// ===== Voice connection resilience =====
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
// user" apart from "crashed/half-open socket". Peers silent for too long are
// dropped locally; a dead server socket triggers an automatic rejoin.
const VOICE_PEER_TIMEOUT_MS = 7000;
const VOICE_PING_INTERVAL_MS = 2000;
const VOICE_SERVER_TIMEOUT_MS = 10000;
const VOICE_MAX_RECONNECTS = 2;
let peerLastSeen = {};
let deadPeers = new Set();
let lastVoiceServerPacketAt = 0;
let voicePingTimer = null;
let voiceReconnectAttempts = 0;
let lastVoiceServerUrl = null;
function cleanupPeerMedia(userId) {
for (const aid of [userId, `${userId}_screen`]) {
if (remoteAudio[aid]) {
if (audioDecryptChains[aid]) audioDecryptChains[aid].clear();
delete remoteAudio[aid];
delete audioDecryptChains[aid];
}
}
for (const decoderId of Object.keys(remoteVideos)) {
if (decoderId.split('-')[0] === userId) {
try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
delete remoteVideos[decoderId];
delete videoDecryptChains[decoderId];
}
}
}
function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
const iv = crypto.getRandomValues(new Uint8Array(12));
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer);
}).catch(() => {});
}
let voiceDragState = false;
let voiceResizeAbort = null;
function clampVoiceWindowToViewport(popupEl) {
const r = popupEl.getBoundingClientRect();
const W = window.innerWidth, H = window.innerHeight;
const w = Math.min(r.width, W);
const h = Math.min(r.height, H);
const l = Math.min(Math.max(r.left, 0), W - w);
const t = Math.min(Math.max(r.top, 0), H - h);
if (l !== r.left || t !== r.top || w !== r.width || h !== r.height) {
popupEl.style.right = "auto";
popupEl.style.bottom = "auto";
popupEl.style.width = w + "px";
popupEl.style.height = h + "px";
popupEl.style.left = l + "px";
popupEl.style.top = t + "px";
}
}
let voiceVolumeState = {};
function getStoredVolume(type, clientId) {
let v = voiceVolumeState[`${type}:${clientId}`];
return v === undefined ? 100 : v;
}
// Voice device/media settings - persisted locally, edited from the in-call
// settings popup (microphone, speaker, camera, quality/fps, mic sensitivity).
const VOICE_SETTINGS_KEY = "larpix_voice_settings";
const VOICE_SENSITIVITY_DEFAULT = -65;
const VOICE_RES_OPTIONS = [
{ value: "480x360", label: "360p" },
{ value: "640x480", label: "480p" },
{ value: "1280x720", label: "720p" },
{ value: "1920x1080", label: "1080p" },
{ value: "source", label: "SOURCE" }
];
const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
let voiceSettings = loadVoiceSettings();
function loadVoiceSettings() {
const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
try {
return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
} catch (e) {
return defaults;
}
}
function saveVoiceSettings() {
try { localStorage.setItem(VOICE_SETTINGS_KEY, JSON.stringify(voiceSettings)); } catch (e) {}
}
function getVoiceMediaConfig(typeId) {
return typeId === 1
? { res: voiceSettings.camRes, fps: parseInt(voiceSettings.camFps) || 24 }
: { res: voiceSettings.screenRes, fps: parseInt(voiceSettings.screenFps) || 30 };
}
// Camera quality presets are treated as single-axis quality tiers (e.g. the
// longer edge of "1280x720"): the camera always opens at its NATIVE aspect
// ratio and is never steered toward a preset's own ratio like 4:3. Nothing is
// remembered - the actual geometry is detected from the live track settings
// every time (encoder config follows it automatically).
function buildVoiceMicConstraints(deviceId) {
const constraints = { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } };
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
return constraints;
}
async function switchVoiceMic(deviceId) {
if (!audioContext || !senderNode) return;
let newStream;
try {
newStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(deviceId));
} catch (e) {
showBlahNotification("error:call.microphone");
return;
}
if (micStream) micStream.getTracks().forEach(t => t.stop());
micStream = newStream;
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} }
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
}
// Output device selection only applies where the web layer owns the output.
// With the native Android routing the OS owns the call audio path entirely.
async function applyVoiceSpeakerSink() {
if (getNativeVoiceRouting() || !voiceSettings.speakerId) return;
try { if (audioContext && audioContext.setSinkId) await audioContext.setSinkId(voiceSettings.speakerId); } catch (e) {}
try { if (voiceOutputAudio && voiceOutputAudio.setSinkId) await voiceOutputAudio.setSinkId(voiceSettings.speakerId); } catch (e) {}
}
let voiceConnectedAt = null;
let voiceTimerInterval = null;
let voicePingMs = null;
function formatVoiceDuration(ms) {
let totalSec = Math.floor(ms / 1000);
let h = Math.floor(totalSec / 3600);
let m = Math.floor((totalSec % 3600) / 60);
let s = totalSec % 60;
let mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
}
function updateVoicePing() {
let pingEl = document.getElementById("voice-ping");
if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : "";
}
function isMobileVoiceDevice() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) && !!window.RTCPeerConnection;
}
function getNativeVoiceRouting() {
try {
return window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.VoiceRouting ? window.Capacitor.Plugins.VoiceRouting : null;
} catch (e) {
return null;
}
}
function teardownVoiceLoopback() {
if (voiceLoopWatchdog) { clearTimeout(voiceLoopWatchdog); voiceLoopWatchdog = null; }
document.removeEventListener("click", voicePlayRetry, true);
window.removeEventListener("voicemodechanged", voiceModeChangedHandler);
if (voiceDeviceChangeHandler && navigator.mediaDevices && navigator.mediaDevices.removeEventListener) {
navigator.mediaDevices.removeEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceDeviceChangeHandler = null;
clearTimeout(voiceDeviceChangeTimer);
if (voiceLoopSendPc) { try { voiceLoopSendPc.close(); } catch (e) {} voiceLoopSendPc = null; }
if (voiceLoopRecvPc) { try { voiceLoopRecvPc.close(); } catch (e) {} voiceLoopRecvPc = null; }
if (voicePlayoutDest) { try { voicePlayoutDest.disconnect(); } catch (e) {} voicePlayoutDest = null; }
if (voiceOutputAudio) {
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.remove();
voiceOutputAudio = null;
}
voiceRemoteStream = null;
}
function voicePlayRetry() {
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
}
function int16ToBase64(view) {
let bin = '';
const u8 = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const STEP = 8192;
for (let i = 0; i < u8.length; i += STEP) {
bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length)));
}
return btoa(bin);
}
function handlePcmChunk(ev) {
const native = getNativeVoiceRouting();
if (!native || !native.nativeAudioFeed || !ev.data) return;
try {
native.nativeAudioFeed({ data: int16ToBase64(new Int16Array(ev.data)) }).catch(() => {});
} catch (e) {}
}
function stopNativePlayback() {
const native = getNativeVoiceRouting();
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {});
}
// Fired natively when Android reverted MODE_IN_COMMUNICATION and the plugin
// restored it - Chromium's playout track must be re-initialized afterwards.
function voiceModeChangedHandler() {
console.debug("Voice: native mode restore detected, replugging playout");
setTimeout(() => replugVoiceOutputElement(), 150);
setTimeout(() => replugVoiceOutputElement(), 700);
}
function voicePlayoutOk() {
let connected = voiceLoopRecvPc && voiceLoopRecvPc.connectionState === "connected";
let playing = voiceOutputAudio && !voiceOutputAudio.paused;
return !!connected && !!playing;
}
// Re-attaching the stream forces the browser audio stack to re-initialize its
// output track - needed after communication device changes on Android.
async function replugVoiceOutputElement() {
if (!voiceOutputAudio || !voiceRemoteStream) return;
console.debug("Voice: replugging WebRTC playout element");
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.srcObject = voiceRemoteStream;
try { await voiceOutputAudio.play(); } catch (e) {}
applyVoiceSpeakerSink();
}
function fallbackVoiceToMediaOutput() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
console.warn("Voice: WebRTC playout unavailable, falling back to media output");
teardownVoiceLoopback();
try { receiverNode.disconnect(); } catch (e) {}
try { receiverNode.connect(audioContext.destination); } catch (e) {}
}
function recoverVoiceLoopback() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
const native = getNativeVoiceRouting();
// First aid: re-plug the playout element (fixes stuck routes after device switches)
replugVoiceOutputElement().then(() => {
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
if (native && voiceLoopRestarts < 2) {
voiceLoopRestarts++;
console.warn("Voice: restarting WebRTC loopback (attempt " + voiceLoopRestarts + ")");
const wasSpeaker = isVoiceSpeakerMode;
teardownVoiceLoopback();
isVoiceSpeakerMode = wasSpeaker;
setupVoiceOutput();
} else {
fallbackVoiceToMediaOutput();
}
}, 800);
});
}
function setupVoiceOutput() {
if (!audioContext || !receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (screenReceiverNode) {
try { screenReceiverNode.disconnect(); } catch (e) {}
screenReceiverNode.connect(audioContext.destination);
}
const native = getNativeVoiceRouting();
// App + native plugin: the mixed call audio is played by a native AudioTrack
// (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
// earpiece/speaker switch. Nothing is output through the WebAudio graph.
// The receiver worklet forwards mixed PCM straight here (~20 ms packets).
if (native && isMobileVoiceDevice()) {
stopNativePlayback();
receiverNode.port.onmessage = handlePcmChunk;
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return;
}
if (!isMobileVoiceDevice()) {
receiverNode.connect(audioContext.destination);
return;
}
// Browser-only speaker mode plays through plain media output.
// With the native plugin present, routing stays on the call path
// and the speaker switch is handled natively.
if (isVoiceSpeakerMode) {
receiverNode.connect(audioContext.destination);
return;
}
voiceLoopRestarts = 0;
teardownVoiceLoopback();
voicePlayoutDest = audioContext.createMediaStreamDestination();
receiverNode.connect(voicePlayoutDest);
voiceLoopSendPc = new RTCPeerConnection();
voiceLoopRecvPc = new RTCPeerConnection();
voiceLoopSendPc.onicecandidate = e => { if (e.candidate) try { voiceLoopRecvPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onicecandidate = e => { if (e.candidate) try { voiceLoopSendPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onconnectionstatechange = () => {
const st = voiceLoopRecvPc && voiceLoopRecvPc.connectionState;
if (st === "failed") recoverVoiceLoopback();
};
voiceLoopRecvPc.ontrack = e => {
voiceRemoteStream = e.streams[0];
if (!voiceOutputAudio) {
voiceOutputAudio = document.createElement("audio");
voiceOutputAudio.autoplay = true;
voiceOutputAudio.setAttribute("playsinline", "");
document.body.appendChild(voiceOutputAudio);
}
voiceOutputAudio.srcObject = voiceRemoteStream;
voiceOutputAudio.play().catch(() => {});
applyVoiceSpeakerSink();
};
document.addEventListener("click", voicePlayRetry, true);
window.addEventListener("voicemodechanged", voiceModeChangedHandler);
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
voiceDeviceChangeHandler = () => {
clearTimeout(voiceDeviceChangeTimer);
// Audio route changed (BT/headset/speaker) - re-init the playout track
voiceDeviceChangeTimer = setTimeout(() => replugVoiceOutputElement(), 400);
};
navigator.mediaDevices.addEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceLoopSendPc.addTrack(voicePlayoutDest.stream.getAudioTracks()[0], voicePlayoutDest.stream);
voiceLoopSendPc.createOffer()
.then(offer => voiceLoopSendPc.setLocalDescription(offer))
.then(() => voiceLoopRecvPc.setRemoteDescription(voiceLoopSendPc.localDescription))
.then(() => voiceLoopRecvPc.createAnswer())
.then(answer => voiceLoopRecvPc.setLocalDescription(answer))
.then(() => voiceLoopSendPc.setRemoteDescription(voiceLoopRecvPc.localDescription))
.catch(() => {
recoverVoiceLoopback();
});
// Safety net: verify the WebRTC playout actually produces sound and repair it if not
if (voiceLoopWatchdog) clearTimeout(voiceLoopWatchdog);
voiceLoopWatchdog = setTimeout(() => {
voiceLoopWatchdog = null;
if (!voicePlayoutDest) return;
voicePlayRetry();
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
recoverVoiceLoopback();
}, 600);
}, 4000);
}
window.toggleVoiceSpeakerMode = async function() {
isVoiceSpeakerMode = !isVoiceSpeakerMode;
let btn = document.getElementById("voice-btn-speaker");
if (btn) btn.classList.toggle("voice-btn-active", isVoiceSpeakerMode);
const native = getNativeVoiceRouting();
if (native && native.setSpeaker) {
// Native routing keeps playback on the call volume stream - only the
// physical output (earpiece <-> loudspeaker) changes, like a phone call.
// The native AudioTrack follows the communication device automatically.
try { await native.setSpeaker({ enabled: isVoiceSpeakerMode }); } catch (e) {}
setTimeout(() => replugVoiceOutputElement(), 250);
setTimeout(() => replugVoiceOutputElement(), 900);
setTimeout(() => { if (voicePlayoutDest && !voicePlayoutOk()) recoverVoiceLoopback(); }, 1700);
return;
}
if (!receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (!isVoiceSpeakerMode && voicePlayoutDest) {
receiverNode.connect(voicePlayoutDest);
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
} else {
receiverNode.connect(audioContext.destination);
if (voiceOutputAudio) { try { voiceOutputAudio.pause(); } catch (e) {} }
}
};
function setVoiceStatus(state) {
let el = document.getElementById("voice-status");
if (!el) return;
el.classList.toggle("connected", state === "connected");
el.classList.toggle("connecting", state === "connecting");
el.classList.toggle("disconnected", state === "disconnected");
let textEl = el.querySelector(".voice-status-text");
if (state === "connected") {
if (voiceConnectedAt === null) voiceConnectedAt = Date.now();
if (!voiceTimerInterval) {
voiceTimerInterval = setInterval(() => {
let tickEl = document.querySelector("#voice-status .voice-status-text");
if (tickEl && voiceConnectedAt !== null) tickEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt);
}, 1000);
}
if (textEl) textEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt);
updateVoicePing();
} else {
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
voiceConnectedAt = null;
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
}
}
function setTileSpeaking(clientId, speaking, streamDecoderId = null) {
let tile = document.getElementById(`voice-tile-${clientId}`);
if (streamDecoderId) {
let streamTile = document.getElementById(`voice-tile-stream-${streamDecoderId}`);
if (streamTile) tile = streamTile;
}
if (!tile) return;
if (tile._speakingTimer) {
clearTimeout(tile._speakingTimer);
tile._speakingTimer = null;
}
if (speaking) {
tile.classList.add("speaking");
tile._speakingTimer = setTimeout(() => {
tile._speakingTimer = null;
tile.classList.remove("speaking");
}, SPEAKING_HANGOVER_MS);
} else {
tile.classList.remove("speaking");
}
}
function clearAllSpeaking() {
for (const el of document.querySelectorAll(".voice-tile.speaking")) {
if (el._speakingTimer) { clearTimeout(el._speakingTimer); el._speakingTimer = null; }
el.classList.remove("speaking");
}
}
setInterval(() => {
const now = Date.now();
let changed = false;
for (const [decoderId, data] of Object.entries(remoteVideos)) {
if (now - data.lastUpdate > 3000) {
if (data.decoder) { try { data.decoder.close(); } catch (e) {} }
if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
delete remoteVideos[decoderId];
delete videoDecryptChains[decoderId];
changed = true;
}
}
// Stale remote audio streams - quiet users send nothing, but their entry
// is recreated on their very next packet, so pruning is always safe.
for (const aid of Object.keys(remoteAudio)) {
if (now - remoteAudio[aid].lastUpdate > 6000) {
const rn = aid.endsWith("_screen") ? screenReceiverNode : receiverNode;
if (rn) rn.port.postMessage({action: 'remove', clientId: aid});
if (audioDecryptChains[aid]) audioDecryptChains[aid].clear();
delete remoteAudio[aid];
delete audioDecryptChains[aid];
}
}
// Ghost peers: still on the room roster but totally silent (pings count as
// life) - drop them locally until the server or the peer proves otherwise.
let ghostRemoved = false;
for (const u of voiceActiveUsers) {
if (u === String(window.id)) continue;
if (!peerLastSeen[u]) { peerLastSeen[u] = now; continue; }
if (now - peerLastSeen[u] > VOICE_PEER_TIMEOUT_MS && !deadPeers.has(u)) {
deadPeers.add(u);
cleanupPeerMedia(u);
ghostRemoved = true;
}
}
if (ghostRemoved) {
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
updateVoiceUIUsers();
}
// Half-open socket watchdog + backgrounded-tab audio recovery.
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
// Alive peers ping every 2s regardless of mute, so total inbound
// silence while others are present means the socket is dead.
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
console.warn("Voice: no inbound traffic, forcing reconnect");
try { currentVoiceWs.close(); } catch (e) {}
} else if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
}
if (changed) updateVoiceUIUsers();
}, 2000);
const voiceWorkletCode = `
class AudioProcessor extends AudioWorkletProcessor {
constructor() { super(); this.buffer = new Int16Array(1024); this.pointer = 0; }
process(inputs) {
const input = inputs[0][0];
if (!input) return true;
let sumSquares = 0;
for (let i = 0; i < input.length; i++) {
let s = Math.max(-1, Math.min(1, input[i]));
sumSquares += s * s;
this.buffer[this.pointer++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
if (this.pointer >= 1024) {
const rms = Math.sqrt(sumSquares / 1024);
const db = 20 * Math.log10(rms || 0.0001);
this.port.postMessage({ audioData: this.buffer.buffer, db: db },[this.buffer.buffer]);
this.buffer = new Int16Array(1024);
this.pointer = 0;
sumSquares = 0;
}
} return true;
}
}
class AudioReceiver extends AudioWorkletProcessor {
// Jitter buffer targets in samples (@48 kHz):
// PREBUF_START - initial prebuffer before first playback (~43 ms)
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
// Backlog slightly above target is drained by a gentle trim (1 sample per
// quantum) instead of letting latency creep up to the hard limit.
constructor(options) {
super();
this.users = {};
this.volumes = {};
const opts = (options && options.processorOptions) || {};
this.forward = !!opts.forward;
this.acc = new Int16Array(960); // ~20 ms @ 48 kHz
this.accLen = 0;
this.port.onmessage = (e) => {
if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; return; }
if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
const { clientId, audioData } = e.data;
if (!this.users[clientId]) this.users[clientId] = { buffer: new Int16Array(65536), readPtr: 0, writePtr: 0, buffering: true, started: false };
const user = this.users[clientId];
const intData = new Int16Array(audioData);
for (let i = 0; i < intData.length; i++) {
user.buffer[user.writePtr] = intData[i];
user.writePtr = (user.writePtr + 1) % 65536;
}
};
}
process(inputs, outputs) {
const output = outputs[0][0];
if (!output) return true;
output.fill(0);
for (const id in this.users) {
const user = this.users[id];
let available = (user.writePtr - user.readPtr + 65536) % 65536;
if (user.buffering && available >= (user.started ? 1536 : 2048)) { user.buffering = false; user.started = true; }
if (!user.buffering) {
if (available < output.length) { user.buffering = true; }
else {
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
for (let i = 0; i < output.length; i++) {
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
user.readPtr = (user.readPtr + 1) % 65536;
}
}
}
}
if (this.forward && this.port) {
for (let i = 0; i < output.length; i++) {
const s = Math.max(-1, Math.min(1, output[i]));
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
if (this.accLen >= this.acc.length) {
this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
this.acc = new Int16Array(960);
this.accLen = 0;
}
}
}
return true;
}
}
registerProcessor('audio-sender', AudioProcessor);
registerProcessor('audio-receiver', AudioReceiver);`;
class TaskQueue {
constructor() { this.queue = []; this.isProcessing = false; }
enqueue(task) { this.queue.push(task); this.processNext(); }
async processNext() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
try { await this.queue.shift()(); } catch (e) { }
this.isProcessing = false;
this.processNext();
}
clear() { this.queue = []; }
}
async function initiateVoiceCall(roomId = null) {
if (currentVoiceWs) {
showBlahNotification("error:already.in.call");
return;
}
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId;
showAction("action.call.init", "callinit");
let res = await fetchEncrypted("room/voice/join", JSON.stringify({
string1: currentVoiceCallType,
string2: currentVoiceCallId
}));
clearAction("callinit");
if (res.startsWith("success:")) {
let bestIp = res.split("success:")[1];
if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
}
await startVoiceSession(bestIp);
} else {
showBlahNotification(res);
}
}
async function startVoiceSession(serverUrl) {
lastVoiceServerUrl = serverUrl;
showVoiceUI();
updateVoiceUIUsers();
setVoiceStatus("connecting");
updateVoiceChatTitle();
try {
const wsUrl = new URL(serverUrl);
if (!wsUrl.pathname.endsWith("/ws")) {
wsUrl.pathname = (wsUrl.pathname === "/" ? "" : wsUrl.pathname) + "/ws";
}
wsUrl.searchParams.set("room", currentVoiceCallId);
wsUrl.searchParams.set("userId", window.id);
wsUrl.searchParams.set("secret", "none");
currentVoiceWs = new WebSocket(wsUrl.toString());
currentVoiceWs.binaryType = 'arraybuffer';
currentVoiceWs.onopen = async () => {
setVoiceStatus("connected");
let btn = document.getElementById("voice-btn-mic");
if (btn) {
if (isMicMuted) btn.classList.add("voice-btn-danger");
else btn.classList.remove("voice-btn-danger");
}
const fetchUsers = async () => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
try {
const baseUrl = currentVoiceWs.url.split('/ws')[0];
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
const t0 = performance.now();
const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
const usersRaw = await res.json();
voicePingMs = Math.round(performance.now() - t0);
updateVoicePing();
const rawUsers = usersRaw.map(u => String(u));
// Peers we locally declared dead stay hidden until either
// they send anything again or the server drops them.
for (const gone of [...deadPeers]) {
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
}
const users = rawUsers.filter(u => !deadPeers.has(u));
let changed = (users.length !== voiceActiveUsers.length);
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
if(changed) {
voiceActiveUsers = users;
updateVoiceUIUsers();
}
} catch (e) {}
};
fetchUsers();
voiceWsHeartbeat = setInterval(fetchUsers, 5000);
voiceReconnectAttempts = 0;
lastVoiceServerPacketAt = Date.now();
if (voicePingTimer) clearInterval(voicePingTimer);
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
// Reconnects reuse the live mic stream - only capture a new one
// when the previous is gone (full teardown or track ended).
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
}
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {}
}
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
applyVoiceSpeakerSink();
setupVoiceOutput();
if (micSourceNode) {
try { micSourceNode.disconnect(); } catch (e) {}
micSourceNode = null;
}
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
let vadHangover = 0;
senderNode.port.onmessage = async (e) => {
if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
// Backpressure valve: when the socket is congested, drop mic
// frames instead of queueing them - queued audio would arrive
// minutes late and drown the connection for every stream.
if (currentVoiceWs.bufferedAmount > 256 * 1024) {
vadHangover = 0;
return;
}
const {audioData, db} = e.data;
const sensitivity = voiceSettings.sensitivity;
const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100));
let micLevelBar = document.getElementById("voice-mic-level");
let vdsLevelFill = document.getElementById("vds-sens-fill");
if (micLevelBar) micLevelBar.style.width = percent + '%';
if (vdsLevelFill) vdsLevelFill.style.width = percent + '%';
if (db >= sensitivity) vadHangover = 75;
let isSpeaking = vadHangover > 0 && !isMicMuted;
setTileSpeaking(String(window.id), isSpeaking);
if (vadHangover > 0 && !isMicMuted) {
vadHangover--;
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceAesCryptoKey) return;
const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer);
} else {
if (micLevelBar) micLevelBar.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';
}
};
};
currentVoiceWs.onmessage = async (e) => {
const arrayBuffer = e.data;
const view = new DataView(arrayBuffer);
let clientId = view.getBigUint64(0, true).toString();
// Any inbound frame proves this peer (and the socket) are alive.
lastVoiceServerPacketAt = Date.now();
peerLastSeen[clientId] = lastVoiceServerPacketAt;
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
const type = view.getUint8(8);
if (type === 5) return; // liveness ping - presence already recorded
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
const baseUrl = currentVoiceWs.url.split('/ws')[0];
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
updateVoiceUIUsers();
}).catch(()=>{});
}
const isKeyFrame = view.getUint8(9) === 1;
const iv = new Uint8Array(arrayBuffer, 10, 12);
const ciphertext = new Uint8Array(arrayBuffer, 22);
if (!voiceAesCryptoKey) return;
if (type === 0 || type === 4) {
const isScreenAudio = (type === 4);
const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
remoteAudio[audioId].lastUpdate = Date.now();
if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => {
try {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {}
});
} else if (type === 1 || type === 2) {
const decoderId = `${clientId}-${type}`;
if (!videoDecryptChains[decoderId]) videoDecryptChains[decoderId] = new TaskQueue();
videoDecryptChains[decoderId].enqueue(async () => {
try {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if (!remoteVideos[decoderId]) {
const canvas = document.createElement('canvas');
remoteVideos[decoderId] = {
lastUpdate: Date.now(),
hasKeyFrame: false,
decoder: null,
canvas: canvas
};
updateVoiceUIUsers();
}
const streamData = remoteVideos[decoderId];
streamData.lastUpdate = Date.now();
if (!streamData.decoder) {
if (!isKeyFrame) return;
const canvas = streamData.canvas;
const ctx = canvas.getContext('2d');
streamData.decoder = new VideoDecoder({
output: (frame) => {
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
frame.close();
},
error: e => {
if (remoteVideos[decoderId]) {
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
remoteVideos[decoderId].decoder = null;
remoteVideos[decoderId].hasKeyFrame = false;
}
}
});
streamData.decoder.configure({codec: 'av01.0.04M.08'});
streamData.hasKeyFrame = true;
}
if (!streamData.hasKeyFrame) {
if (isKeyFrame) streamData.hasKeyFrame = true;
else return;
}
const chunk = new EncodedVideoChunk({
type: isKeyFrame ? 'key' : 'delta',
timestamp: Math.trunc(performance.now() * 1000),
data: decryptedBuffer
});
try {
streamData.decoder.decode(chunk);
} catch(err) {
try { streamData.decoder.close(); } catch(e){}
streamData.decoder = null;
streamData.hasKeyFrame = false;
}
} catch (err) {}
});
} else if (type === 3) {
crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
const stoppedTypeId = new Uint8Array(decrypted)[0];
if (stoppedTypeId === 4) {
const audioId = `${clientId}_screen`;
if (remoteAudio[audioId]) {
if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
if(screenReceiverNode) screenReceiverNode.port.postMessage({ action: 'remove', clientId: audioId });
}
} else {
const decoderId = `${clientId}-${stoppedTypeId}`;
if (remoteVideos[decoderId]) {
try { if(remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
if(videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
delete remoteVideos[decoderId]; delete videoDecryptChains[decoderId];
updateVoiceUIUsers();
}
}
}).catch(() => {});
}
};
currentVoiceWs.onclose = (ev) => {
// Server closes with reason "Kicked" when another connection took
// our userId slot (second device/tab). Reconnecting would start an
// infinite kick-war - give up cleanly instead. Any other close
// (network drop, watchdog) is a reconnect candidate.
if (ev && ev.reason === "Kicked") {
endVoiceSession();
return;
}
scheduleVoiceReconnect();
};
} catch(err) {
console.error(err);
showBlahNotification("error:call.connect.failed");
endVoiceSession();
}
}
function handleCallUpdate(data) {
let parts = data.split(":");
let roomId = parts[1];
let state = [];
if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":"));
state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
}
voiceActiveUsers = state;
if (currentVoiceCallId === roomId) {
let myIdStr = String(window.id);
if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
.then(k => voiceAesCryptoKey = k);
}
if (voiceAesKeyBytes) {
for (let u of state) {
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
}
}
updateVoiceUIUsers();
for (let u in remoteAudio) {
let pureU = u.replace("_screen", "");
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
delete audioDecoders[u];
delete remoteAudio[u];
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
}
}
for (let decoderId in remoteVideos) {
let pureU = decoderId.split('-')[0];
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
if (remoteVideos[decoderId].decoder) { try { remoteVideos[decoderId].decoder.close(); } catch(e){} }
if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
delete remoteVideos[decoderId];
delete videoDecryptChains[decoderId];
updateVoiceUIUsers();
}
}
} else if (state.length > 0 && !state.includes(String(window.id))) {
// Someone is in a call, and we are not. Show incoming call banner if it's the current room.
if (window.currentDmId === roomId) {
showIncomingCallUI(roomId);
}
} else if (state.length === 0) {
dismissIncomingCall();
}
}
async function resolveCallerInfo(roomId) {
if (window.fetchEncrypted) {
try {
let infoPayload = await fetchEncrypted("group/info", roomId);
if (infoPayload && infoPayload.startsWith("{")) {
let info = JSON.parse(infoPayload);
if (info.string2) {
let pfpUrl = null;
if (window.getGroupAvatarUrl) {
try { pfpUrl = await getGroupAvatarUrl(roomId, info.string2); } catch (e) {}
}
return { name: info.string2, pfpUrl };
}
}
} catch (e) {}
}
let targetId = String(roomId);
let parts = targetId.split('_');
let myId = `${window.id};${window.host}`;
if (parts.length === 2 && (parts[0] === myId || parts[1] === myId)) {
targetId = parts[0] === myId ? parts[1] : parts[0];
}
let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
if (!name && window.fetchAsync) {
try {
let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`);
if (fetched && !fetched.startsWith("error:")) {
name = fetched;
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = fetched;
}
} catch (e) {}
}
let pfpUrl = window.dmPfpCache ? window.dmPfpCache[targetId] : null;
if (!pfpUrl && window.getAvatarUrl) {
try { pfpUrl = await window.getAvatarUrl(targetId, name || "User"); } catch (e) {}
if (pfpUrl && window.dmPfpCache) window.dmPfpCache[targetId] = pfpUrl;
}
return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl };
}
function showIncomingCallUI(roomId) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null;
let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup";
incomingUi.innerHTML = `