Compare commits
No commits in common. "ffd952b1f0582050be155ec3fc6f6c56985b69d2" and "e3b1a6fc8bd87ae700fc53b4ca2c941fe305895f" have entirely different histories.
ffd952b1f0
...
e3b1a6fc8b
8 changed files with 166 additions and 704 deletions
|
|
@ -4206,55 +4206,6 @@ function handleChatInputKey(event) {
|
||||||
let dmMessagePollInterval = null;
|
let dmMessagePollInterval = null;
|
||||||
let appWebSocket = null;
|
let appWebSocket = null;
|
||||||
let dmMessageLoadTimeout = null;
|
let dmMessageLoadTimeout = null;
|
||||||
let appWsPingTimer = null;
|
|
||||||
let callStatesResyncTimer = null;
|
|
||||||
|
|
||||||
//Keepalive app-WS: tekstowy "ping" co 20 s. Serwer zamyka połączenie po 75 s
|
|
||||||
//bezczynności (AppWsIdleTimeout) - bez tego half-open TCP po zabiciu aplikacji /
|
|
||||||
//zmianie sieci wisiał na serwerze godzinami, cleanup ActiveCalls nie biegł i
|
|
||||||
//wskaźniki rozmów pokazywały uczestników, którzy dawno wyszli. Ping utrzymuje
|
|
||||||
//też mapping NAT przy danych komórkowych, więc eventy (call_update!) nie giną.
|
|
||||||
function startAppWsPing() {
|
|
||||||
if (appWsPingTimer) clearInterval(appWsPingTimer);
|
|
||||||
appWsPingTimer = setInterval(() => {
|
|
||||||
if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) {
|
|
||||||
try { appWebSocket.send("ping"); } catch (e) {}
|
|
||||||
}
|
|
||||||
}, 20000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAppWsPing() {
|
|
||||||
if (appWsPingTimer) { clearInterval(appWsPingTimer); appWsPingTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
//Pełna resynchronizacja wskaźników rozmów: zgubiony podczas przerwy w app-WS
|
|
||||||
//call_update NIGDY nie jest odtwarzany przez serwer, a seedCallStateFromList
|
|
||||||
//tylko dodaje wpisy - bez tego wskaźniki "znikały dopiero po ręcznym odświeżeniu".
|
|
||||||
//Dla każdego pokoju znanego jako aktywny (+ otwartego) pyta room/voice/state
|
|
||||||
//i koryguje mapę oraz UI. Uruchamiane: po (re)otwarciu WS, co 30 s, przy powrocie
|
|
||||||
//karty/aplikacji na wierzch.
|
|
||||||
async function resyncAllCallStates() {
|
|
||||||
if (!window.id || typeof fetchEncrypted !== "function" || typeof ensureActiveCallsMap !== "function") return;
|
|
||||||
let map = ensureActiveCallsMap();
|
|
||||||
let keys = Object.keys(map);
|
|
||||||
let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
|
|
||||||
if (currentKey && !keys.includes(currentKey)) keys.push(currentKey);
|
|
||||||
if (keys.length === 0) return;
|
|
||||||
|
|
||||||
for (let key of keys) {
|
|
||||||
let roomType = window.currentDmId === key && window.currentRoomType ? window.currentRoomType
|
|
||||||
: (String(key).includes("_") ? "dms" : "groups");
|
|
||||||
try {
|
|
||||||
let res = await fetchEncrypted("room/voice/state", JSON.stringify({ string1: roomType, string2: key }));
|
|
||||||
if (!res || !res.startsWith("[")) continue;
|
|
||||||
let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
|
|
||||||
if (participants.length > 0) map[key] = participants;
|
|
||||||
else delete map[key];
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
//Po serii poprawek pełne przerysowanie wskaźników (listy + top-bary + details)
|
|
||||||
if (typeof updateVoicePresenceUI === "function") updateVoicePresenceUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupWebSocket() {
|
function setupWebSocket() {
|
||||||
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
|
@ -4277,17 +4228,6 @@ function setupWebSocket() {
|
||||||
} finally {
|
} finally {
|
||||||
release();
|
release();
|
||||||
}
|
}
|
||||||
|
|
||||||
startAppWsPing();
|
|
||||||
//Po każdym (re)połączeniu odtwarzamy stan wskaźników - eventy call_update
|
|
||||||
//z czasu rozłączenia są bezpowrotnie stracone
|
|
||||||
resyncAllCallStates();
|
|
||||||
if (!callStatesResyncTimer) {
|
|
||||||
callStatesResyncTimer = setInterval(() => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
resyncAllCallStates();
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
appWebSocket.onmessage = (event) => {
|
appWebSocket.onmessage = (event) => {
|
||||||
|
|
@ -4333,22 +4273,10 @@ function setupWebSocket() {
|
||||||
};
|
};
|
||||||
|
|
||||||
appWebSocket.onclose = () => {
|
appWebSocket.onclose = () => {
|
||||||
stopAppWsPing();
|
|
||||||
setTimeout(setupWebSocket, 5000);
|
setTimeout(setupWebSocket, 5000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
//Powrót karty/aplikacji na wierzch: mobilne WebView potrafią trzymać martwy
|
|
||||||
//socket odkryty bardzo długo (brak ramek = brak detekcji). Wymuszamy próbę
|
|
||||||
//połączenia i od razu resynchronizujemy wskaźniki rozmów.
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
if (appWebSocket && appWebSocket.readyState !== WebSocket.OPEN && appWebSocket.readyState !== WebSocket.CONNECTING) {
|
|
||||||
setupWebSocket();
|
|
||||||
}
|
|
||||||
resyncAllCallStates();
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentContextMenuMsgId = null;
|
let currentContextMenuMsgId = null;
|
||||||
let touchContextMenuFired = false;
|
let touchContextMenuFired = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ var invitesEntry = `
|
||||||
|
|
||||||
var chatScreen = `
|
var chatScreen = `
|
||||||
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
|
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
|
||||||
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; overflow-x: hidden; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
</div>
|
</div>
|
||||||
<div id="replying-bar" style="display: none; padding: 0 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
|
<div id="replying-bar" style="display: none; padding: 0 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
|
||||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
|
||||||
|
|
|
||||||
|
|
@ -686,7 +686,6 @@ space{
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
.room-pfp {
|
.room-pfp {
|
||||||
width: 2.2rem;
|
width: 2.2rem;
|
||||||
|
|
@ -739,7 +738,6 @@ space{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.chat-message-header {
|
.chat-message-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1180,15 +1178,7 @@ input:checked + .toggle-slider:before {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Czerwona kropka nieprzeczytanych - pozycjonowana absolutnie. Odstęp liczony tak,
|
|
||||||
żeby ŚRODEK kropki leżał na tej samej osi co środek 1rem-owego .call-indicator
|
|
||||||
(0.6rem paddingu + (1rem - 0.65rem)/2) - inaczej kropka "ucieka" w prawo
|
|
||||||
względem większej ikony słuchawek. */
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
position: absolute;
|
|
||||||
right: calc(0.6rem + (1rem - 0.65rem) / 2);
|
|
||||||
top: 50%;
|
|
||||||
margin-top: -0.325rem;
|
|
||||||
width: 0.65rem;
|
width: 0.65rem;
|
||||||
height: 0.65rem;
|
height: 0.65rem;
|
||||||
min-width: 0.65rem;
|
min-width: 0.65rem;
|
||||||
|
|
@ -1196,6 +1186,7 @@ input:checked + .toggle-slider:before {
|
||||||
background-color: var(--big-red);
|
background-color: var(--big-red);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
let currentVoiceWs = null;
|
let currentVoiceWs = null;
|
||||||
let currentVoiceCallId = null;
|
let currentVoiceCallId = null;
|
||||||
let currentVoiceCallType = null;
|
let currentVoiceCallType = null;
|
||||||
|
let voiceWsHeartbeat = null;
|
||||||
let voiceActiveUsers = [];
|
let voiceActiveUsers = [];
|
||||||
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
|
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
|
||||||
let voiceUiContainer = null;
|
let voiceUiContainer = null;
|
||||||
|
|
@ -106,8 +107,8 @@ function getVoicePeerSenderKey(clientIdStr) {
|
||||||
|
|
||||||
|
|
||||||
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
|
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
|
||||||
let micSourceNode = null, senderNode = null, receiverNode = null;
|
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
|
||||||
let remoteAudio = {}, audioDecryptChains = {};
|
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
|
||||||
let knownVoiceUsers = new Set();
|
let knownVoiceUsers = new Set();
|
||||||
let isMicMuted = false;
|
let isMicMuted = false;
|
||||||
let remoteVideos = {}, videoDecryptChains = {};
|
let remoteVideos = {}, videoDecryptChains = {};
|
||||||
|
|
@ -125,26 +126,22 @@ let isVoiceSpeakerMode = false;
|
||||||
|
|
||||||
const SPEAKING_HANGOVER_MS = 400;
|
const SPEAKING_HANGOVER_MS = 400;
|
||||||
|
|
||||||
//Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione,
|
// ===== Voice connection resilience =====
|
||||||
//więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy
|
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
|
||||||
//14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej.
|
// user" apart from "crashed/half-open socket". Peers silent for too long are
|
||||||
const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024;
|
// dropped locally; a dead server socket triggers an automatic rejoin.
|
||||||
const VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES = 14 * 1024;
|
|
||||||
|
|
||||||
//Resilience: peer bez pakietów >7 s = lokalnie martwy (media wycięte), socket
|
|
||||||
//bez ruchu od ŻYWYCH peerów >15 s = wymuszony reconnect. Pings co 2 s.
|
|
||||||
const VOICE_PEER_TIMEOUT_MS = 7000;
|
const VOICE_PEER_TIMEOUT_MS = 7000;
|
||||||
const VOICE_PING_INTERVAL_MS = 2000;
|
const VOICE_PING_INTERVAL_MS = 2000;
|
||||||
const VOICE_SERVER_TIMEOUT_MS = 15000;
|
const VOICE_SERVER_TIMEOUT_MS = 10000;
|
||||||
const VOICE_MAX_RECONNECTS = 4;
|
const VOICE_MAX_RECONNECTS = 2;
|
||||||
const VOICE_ROSTER_POLL_MS = 5000;
|
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
let lastVoiceServerPacketAt = 0;
|
||||||
|
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
|
||||||
|
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
|
||||||
let voiceHadRemotePeers = false;
|
let voiceHadRemotePeers = false;
|
||||||
let voicePingTimer = null;
|
let voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let voiceRosterPollTimer = null;
|
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
let voiceReconnectAttempts = 0;
|
let voiceReconnectAttempts = 0;
|
||||||
let voiceSocketOpenedAt = 0;
|
let voiceSocketOpenedAt = 0;
|
||||||
|
|
@ -170,21 +167,11 @@ function cleanupPeerMedia(userId) {
|
||||||
function sendVoicePing() {
|
function sendVoicePing() {
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
//Typ 5: prośba o echo. Payload = 8B timestamp (ms, WŁASNY zegar). Odbiorca
|
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
|
||||||
//odsyła te same bajty jako typ 6, a RTT liczę dopiero na jego powrocie.
|
|
||||||
const tsData = BigInt(Date.now());
|
|
||||||
const tsBuf = new ArrayBuffer(8);
|
|
||||||
new DataView(tsBuf).setBigUint64(0, tsData);
|
|
||||||
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array(tsBuf)).then(cipher => {
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
currentVoiceWs.send(payload.buffer);
|
currentVoiceWs.send(payload.buffer);
|
||||||
voiceOutstandingPings.set(tsData, Date.now());
|
|
||||||
const nowMs = Date.now();
|
|
||||||
for (const [k, sentAt] of voiceOutstandingPings) {
|
|
||||||
if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,15 +217,9 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
|
||||||
let voiceSettings = loadVoiceSettings();
|
let voiceSettings = loadVoiceSettings();
|
||||||
|
|
||||||
function loadVoiceSettings() {
|
function loadVoiceSettings() {
|
||||||
//Globalny default 480p@24; stare zapisane defaulty (720p/1080p/30fps)
|
const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
|
||||||
//migrują na nowe, świadome wybory użytkownika zostają.
|
|
||||||
const defaults = { micId: "", camId: "", speakerId: "", camRes: "640x480", camFps: 24, screenRes: "640x480", screenFps: 24, sensitivity: VOICE_SENSITIVITY_DEFAULT };
|
|
||||||
try {
|
try {
|
||||||
let s = Object.assign({}, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
|
return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
|
||||||
if (s.camRes === "1280x720") s.camRes = defaults.camRes;
|
|
||||||
if (s.screenRes === "1920x1080") s.screenRes = defaults.screenRes;
|
|
||||||
if (s.screenFps === 30 || s.screenFps === "30") s.screenFps = defaults.screenFps;
|
|
||||||
return Object.assign(defaults, s);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
@ -293,35 +274,6 @@ async function applyVoiceSpeakerSink() {
|
||||||
let voiceConnectedAt = null;
|
let voiceConnectedAt = null;
|
||||||
let voiceTimerInterval = null;
|
let voiceTimerInterval = null;
|
||||||
let voicePingMs = null;
|
let voicePingMs = null;
|
||||||
//Kontrola congestionu: bufferedAmount nie widzi backlogu kernela TCP, prawdziwy
|
|
||||||
//obraz daje RTT z pingów. Baseline = MINIMUM z okna próbek (odporne na skoki
|
|
||||||
//i na peera sztucznie opóźniającego własne echa - stałe kłamstwo podnosi minimum).
|
|
||||||
let voiceRttFastMs = 0;
|
|
||||||
let voiceRttSamples = [];
|
|
||||||
let voiceRttBaseMs = 0;
|
|
||||||
let voiceAudioHoldUntil = 0; //histereza zaworu audio
|
|
||||||
|
|
||||||
//Echo-ping (typ 5 = prośba, typ 6 = echo): RTT wyłącznie na WŁASNYM zegarze -
|
|
||||||
//wysyłam timestamp, peer odbija te same bajty, odejmuję czas nadania. Zegar
|
|
||||||
//peera nigdy nie jest używany (może być przekręcony lub spreparowany).
|
|
||||||
const voiceOutstandingPings = new Map(); // BigInt(ts) -> czas nadania
|
|
||||||
let voiceLastEchoAt = 0; // rate-limit odpowiedzi echem
|
|
||||||
let voiceLastRttSampleAt = 0; // kiedy ostatnio spłynął prawdziwy pomiar
|
|
||||||
|
|
||||||
function voiceCongestionDelay() {
|
|
||||||
if (!voiceRttFastMs || !voiceRttBaseMs) return 0;
|
|
||||||
return Math.min(5000, Math.max(0, voiceRttFastMs - voiceRttBaseMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
function voiceRecordRttSample(rtt) {
|
|
||||||
voiceLastRttSampleAt = Date.now();
|
|
||||||
voicePingMs = voicePingMs === null ? rtt : Math.round(voicePingMs * 0.7 + rtt * 0.3);
|
|
||||||
voiceRttFastMs = voiceRttFastMs ? Math.round(voiceRttFastMs * 0.5 + rtt * 0.5) : rtt;
|
|
||||||
voiceRttSamples.push(rtt);
|
|
||||||
if (voiceRttSamples.length > 12) voiceRttSamples.shift();
|
|
||||||
voiceRttBaseMs = Math.min.apply(null, voiceRttSamples);
|
|
||||||
updateVoicePing();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVoiceDuration(ms) {
|
function formatVoiceDuration(ms) {
|
||||||
let totalSec = Math.floor(ms / 1000);
|
let totalSec = Math.floor(ms / 1000);
|
||||||
|
|
@ -678,11 +630,7 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
//deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP
|
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||||
//jeszcze nie zsynchronizowany przez serwer) nigdy nic nie wyśle, więc
|
|
||||||
//zliczany jak żywy wymuszał reconnecty w pętli - migające "Connecting"
|
|
||||||
//w zdrowej rozmowie.
|
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id) && !deadPeers.has(u)).length;
|
|
||||||
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
||||||
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
||||||
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
||||||
|
|
@ -745,8 +693,8 @@ class AudioReceiver extends AudioWorkletProcessor {
|
||||||
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
||||||
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
||||||
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
||||||
// Backlog above target is drained adaptively (proportional to excess, see
|
// Backlog slightly above target is drained by a gentle trim (1 sample per
|
||||||
// process()) so latency returns to ~43 ms quickly after network bursts.
|
// quantum) instead of letting latency creep up to the hard limit.
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super();
|
super();
|
||||||
this.users = {};
|
this.users = {};
|
||||||
|
|
@ -784,12 +732,7 @@ class AudioReceiver extends AudioWorkletProcessor {
|
||||||
if (available < output.length) { user.buffering = true; }
|
if (available < output.length) { user.buffering = true; }
|
||||||
else {
|
else {
|
||||||
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
||||||
else if (available > 2049) {
|
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
|
||||||
//Adaptacyjny drain: nadmiar nad target odprowadzany proporcjonalnie
|
|
||||||
//(1 + excess/256 /kwant) - powrót z ~128 ms do ~43 ms w ułamku sekundy
|
|
||||||
const excess = available - 2048;
|
|
||||||
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536;
|
|
||||||
}
|
|
||||||
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
|
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
|
||||||
for (let i = 0; i < output.length; i++) {
|
for (let i = 0; i < output.length; i++) {
|
||||||
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
|
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
|
||||||
|
|
@ -947,38 +890,6 @@ function bytesToB64u(a) {
|
||||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
//Roster pokoju z voice-servera (źródło prawdy) -> synchronizacja kafelków.
|
|
||||||
//Steady-poll co VOICE_ROSTER_POLL_MS - bez tego sweep ghosta/leave bez
|
|
||||||
//call_update nigdy nie docierał do UI.
|
|
||||||
async function fetchVoiceRoster() {
|
|
||||||
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=${encodeURIComponent(currentVoiceCallId)}`);
|
|
||||||
const usersRaw = await res.json();
|
|
||||||
//HTTP tylko jako szacunek gdy brak prawdziwych próbek RTT (solo / stary peer)
|
|
||||||
if (voicePingMs === null || Date.now() - voiceLastRttSampleAt > 25000) {
|
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
|
||||||
updateVoicePing();
|
|
||||||
}
|
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
|
||||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
|
||||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
|
||||||
for (const gone of [...deadPeers]) {
|
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
|
||||||
}
|
|
||||||
const users = rawUsers;
|
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
|
||||||
if(changed) {
|
|
||||||
voiceActiveUsers = users;
|
|
||||||
updateVoiceUIUsers();
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startVoiceSession(serverUrl, ticket) {
|
async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (!ticket) throw new Error("missing voice ticket");
|
if (!ticket) throw new Error("missing voice ticket");
|
||||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||||
|
|
@ -1021,7 +932,32 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
else btn.classList.remove("voice-btn-danger");
|
else btn.classList.remove("voice-btn-danger");
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchVoiceRoster();
|
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));
|
||||||
|
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||||
|
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||||
|
for (const gone of [...deadPeers]) {
|
||||||
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
|
}
|
||||||
|
const users = rawUsers;
|
||||||
|
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();
|
||||||
//The voice-server roster is joined only AFTER ticket validation (an
|
//The voice-server roster is joined only AFTER ticket validation (an
|
||||||
//HTTP roundtrip to the main server), so the immediate fetch can still
|
//HTTP roundtrip to the main server), so the immediate fetch can still
|
||||||
//miss us - retry quickly until our own tile shows up instead of
|
//miss us - retry quickly until our own tile shows up instead of
|
||||||
|
|
@ -1035,17 +971,13 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voiceRosterRetryTimer = null;
|
voiceRosterRetryTimer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchVoiceRoster();
|
fetchUsers();
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
voiceReconnectAttempts = 0;
|
voiceReconnectAttempts = 0;
|
||||||
lastVoiceServerPacketAt = Date.now();
|
lastVoiceServerPacketAt = Date.now();
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||||
//Steady roster poll - utrzymuje kafelki zgodne z prawdą serwera
|
|
||||||
//(ghost-sweep, leave bez call_update) przez całą rozmowę.
|
|
||||||
if (voiceRosterPollTimer) clearInterval(voiceRosterPollTimer);
|
|
||||||
voiceRosterPollTimer = setInterval(fetchVoiceRoster, VOICE_ROSTER_POLL_MS);
|
|
||||||
|
|
||||||
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||||
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||||
|
|
@ -1101,18 +1033,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
const myIdStr = String(window.id);
|
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
//Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla
|
// Backpressure valve: when the socket is congested, drop mic
|
||||||
//bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej
|
// frames instead of queueing them - queued audio would arrive
|
||||||
//urwać ułamek sekundy niż mówić z sekundami opóźnienia.
|
// minutes late and drown the connection for every stream.
|
||||||
if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) {
|
if (currentVoiceWs.bufferedAmount > 256 * 1024) {
|
||||||
vadHangover = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700;
|
|
||||||
if (Date.now() < voiceAudioHoldUntil) {
|
|
||||||
vadHangover = 0;
|
vadHangover = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1127,7 +1053,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
|
|
||||||
if (db >= sensitivity) vadHangover = 75;
|
if (db >= sensitivity) vadHangover = 75;
|
||||||
let isSpeaking = vadHangover > 0 && !isMicMuted;
|
let isSpeaking = vadHangover > 0 && !isMicMuted;
|
||||||
setTileSpeaking(myIdStr, isSpeaking);
|
setTileSpeaking(String(window.id), isSpeaking);
|
||||||
|
|
||||||
if (vadHangover > 0 && !isMicMuted) {
|
if (vadHangover > 0 && !isMicMuted) {
|
||||||
vadHangover--;
|
vadHangover--;
|
||||||
|
|
@ -1158,44 +1084,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
|
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
|
||||||
|
|
||||||
const type = view.getUint8(8);
|
const type = view.getUint8(8);
|
||||||
if (type === 5 || type === 6) {
|
if (type === 5) return; // liveness ping - presence already recorded
|
||||||
//Liveness już zapisany wyżej. Dalej tylko przy zainstalowanym kluczu.
|
|
||||||
if (voiceAesKeyBytes) {
|
|
||||||
const senderKey = getVoicePeerSenderKey(clientId);
|
|
||||||
if (senderKey) {
|
|
||||||
//Offsety ODBIORCZE: serwer dokleja 8B nadawcy, więc iv=[10..22),
|
|
||||||
//ct=[22..). Format wysyłki (iv=2, ct=14) jest INNY - pomylenie
|
|
||||||
//psuje każdą deszyfrację cicho (GCM auth fail -> .catch)
|
|
||||||
const piv = new Uint8Array(arrayBuffer, 10, 12);
|
|
||||||
const pct = new Uint8Array(arrayBuffer, 22);
|
|
||||||
crypto.subtle.decrypt({name: "AES-GCM", iv: piv}, senderKey, pct).then(plain => {
|
|
||||||
if (plain.byteLength !== 8) return; //stary klient: ping bez timestampu
|
|
||||||
const tsData = new DataView(plain).getBigUint64(0);
|
|
||||||
if (type === 5) {
|
|
||||||
//Odbij te same bajty własnym podkluczem i świeżym IV; rate-limit
|
|
||||||
const nowMs = Date.now();
|
|
||||||
if (nowMs - voiceLastEchoAt < 150) return;
|
|
||||||
voiceLastEchoAt = nowMs;
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
|
||||||
const riv = nextVoiceIv();
|
|
||||||
crypto.subtle.encrypt({name: "AES-GCM", iv: riv}, voiceMySenderCryptoKey, plain).then(rcipher => {
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
|
||||||
const rpayload = new Uint8Array(14 + rcipher.byteLength);
|
|
||||||
rpayload[0] = 6; rpayload[1] = 0; rpayload.set(riv, 2); rpayload.set(new Uint8Array(rcipher), 14);
|
|
||||||
currentVoiceWs.send(rpayload.buffer);
|
|
||||||
}).catch(() => {});
|
|
||||||
} else {
|
|
||||||
//Typ 6: powrót własnego timestampu - RTT na WŁASNYM zegarze.
|
|
||||||
if (!voiceOutstandingPings.has(tsData)) return;
|
|
||||||
voiceOutstandingPings.delete(tsData);
|
|
||||||
const rtt = Date.now() - Number(tsData);
|
|
||||||
if (rtt >= 1 && rtt < 15000) voiceRecordRttSample(rtt);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
|
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
|
|
@ -1276,7 +1165,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
frame.close();
|
frame.close();
|
||||||
},
|
},
|
||||||
error: e => {
|
error: e => {
|
||||||
voiceLogError('videodecoder[' + decoderId + ']', e);
|
|
||||||
if (remoteVideos[decoderId]) {
|
if (remoteVideos[decoderId]) {
|
||||||
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
||||||
remoteVideos[decoderId].decoder = null;
|
remoteVideos[decoderId].decoder = null;
|
||||||
|
|
@ -1302,14 +1190,11 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
try {
|
try {
|
||||||
streamData.decoder.decode(chunk);
|
streamData.decoder.decode(chunk);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
voiceLogError('videodecode[' + decoderId + ']', err);
|
|
||||||
try { streamData.decoder.close(); } catch(e){}
|
try { streamData.decoder.close(); } catch(e){}
|
||||||
streamData.decoder = null;
|
streamData.decoder = null;
|
||||||
streamData.hasKeyFrame = false;
|
streamData.hasKeyFrame = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
voiceLogError('vdecrypt[' + decoderId + ']', err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -1447,6 +1332,8 @@ async function handleCallUpdate(data) {
|
||||||
for (let u in remoteAudio) {
|
for (let u in remoteAudio) {
|
||||||
let pureU = u.replace("_screen", "");
|
let pureU = u.replace("_screen", "");
|
||||||
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
|
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
|
||||||
|
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
|
||||||
|
delete audioDecoders[u];
|
||||||
delete remoteAudio[u];
|
delete remoteAudio[u];
|
||||||
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
|
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
|
||||||
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
|
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
|
||||||
|
|
@ -2603,6 +2490,7 @@ function toggleVoiceMic() {
|
||||||
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
|
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
|
||||||
function teardownVoiceTransport() {
|
function teardownVoiceTransport() {
|
||||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||||
|
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||||
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
|
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
|
||||||
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
|
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
|
||||||
if (currentVoiceWs) {
|
if (currentVoiceWs) {
|
||||||
|
|
@ -2619,7 +2507,6 @@ function teardownVoiceTransport() {
|
||||||
peerLastSeen = {};
|
peerLastSeen = {};
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
//Stale participants from the previous attempt must not linger in the UI
|
//Stale participants from the previous attempt must not linger in the UI
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
|
|
@ -2629,12 +2516,6 @@ function teardownVoiceTransport() {
|
||||||
setVoiceRootKey(null);
|
setVoiceRootKey(null);
|
||||||
voiceSendEpoch = null;
|
voiceSendEpoch = null;
|
||||||
voiceSendCounter = 0n;
|
voiceSendCounter = 0n;
|
||||||
voiceRttFastMs = 0;
|
|
||||||
voiceRttSamples = [];
|
|
||||||
voiceRttBaseMs = 0;
|
|
||||||
voiceAudioHoldUntil = 0;
|
|
||||||
voiceOutstandingPings.clear();
|
|
||||||
voiceLastRttSampleAt = 0;
|
|
||||||
lastVoiceServerPacketAt = Date.now();
|
lastVoiceServerPacketAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2651,10 +2532,6 @@ function scheduleVoiceReconnect() {
|
||||||
voiceReconnectAttempts++;
|
voiceReconnectAttempts++;
|
||||||
setVoiceStatus("connecting");
|
setVoiceStatus("connecting");
|
||||||
teardownVoiceTransport();
|
teardownVoiceTransport();
|
||||||
//Zmiana sieci = stary RTT dotyczy martwej ścieżki; bez resetu wskaźnik
|
|
||||||
//wisiałby zamrożony, a fallback HTTP nie miał prawa go nadpisać
|
|
||||||
voicePingMs = null;
|
|
||||||
updateVoicePing();
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2666,7 +2543,7 @@ function scheduleVoiceReconnect() {
|
||||||
console.warn("Voice reconnect failed", err);
|
console.warn("Voice reconnect failed", err);
|
||||||
scheduleVoiceReconnect();
|
scheduleVoiceReconnect();
|
||||||
}
|
}
|
||||||
}, Math.min(4000, 800 * voiceReconnectAttempts));
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
function endVoiceSession() {
|
function endVoiceSession() {
|
||||||
|
|
@ -2706,27 +2583,24 @@ function endVoiceSession() {
|
||||||
micStream = null;
|
micStream = null;
|
||||||
}
|
}
|
||||||
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
|
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
|
||||||
|
if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
|
||||||
|
|
||||||
|
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
|
||||||
peerLastSeen = {};
|
peerLastSeen = {};
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceReconnectAttempts = 0;
|
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
voiceHadRemotePeers = false;
|
voiceHadRemotePeers = false;
|
||||||
voiceRttFastMs = 0;
|
|
||||||
voiceRttSamples = [];
|
|
||||||
voiceRttBaseMs = 0;
|
|
||||||
voiceAudioHoldUntil = 0;
|
|
||||||
voiceOutstandingPings.clear();
|
|
||||||
voiceLastEchoAt = 0;
|
|
||||||
voiceLastRttSampleAt = 0;
|
|
||||||
|
|
||||||
|
for (let id in audioDecoders) {
|
||||||
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
}
|
||||||
for (let id in audioDecryptChains) {
|
for (let id in audioDecryptChains) {
|
||||||
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
|
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
|
||||||
}
|
}
|
||||||
|
audioDecoders = {};
|
||||||
audioDecryptChains = {};
|
audioDecryptChains = {};
|
||||||
remoteAudio = {};
|
remoteAudio = {};
|
||||||
|
|
||||||
|
|
@ -2798,11 +2672,8 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||||
screenSenderNode.port.onmessage = async (e) => {
|
screenSenderNode.port.onmessage = async (e) => {
|
||||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
// Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio
|
// Same backpressure valve as the mic sender.
|
||||||
// ma pierwszeństwo przed wideo na wspólnym sockecie.
|
if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
|
||||||
if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) return;
|
|
||||||
if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700;
|
|
||||||
if (Date.now() < voiceAudioHoldUntil) return;
|
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
|
@ -2832,24 +2703,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
return { width, height, fps, bitrate };
|
return { width, height, fps, bitrate };
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0;
|
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||||
//AIMD + slow-start: start na 40% celu (pełną prędkością waliłby w nieznany
|
|
||||||
//uplink, a bufory TCP w kernelu to MB-y = sekundy kolejki zanim cokolwiek
|
|
||||||
//zmierzymy). Sygnał korka: skipy (zawór 12 KB) lub nadwyżka RTT.
|
|
||||||
let brScale = 0.4, encTries = 0, encSkips = 0, brWindowAt = performance.now(), lastBrReconfAt = 0;
|
|
||||||
//Korek trwa mimo podłogi bitrate'u → FPS spada do 1/2, 1/3
|
|
||||||
let fpsDiv = 1, lowStreak = 0, cleanStreak = 0, tickCount = 0;
|
|
||||||
let activeBitrate = Math.round(currentConfig.bitrate * brScale);
|
|
||||||
const applyEncoderBitrate = (force = false) => {
|
|
||||||
const target = Math.max(120000, Math.round(currentConfig.bitrate * brScale));
|
|
||||||
if (!force && Math.abs(target - activeBitrate) / activeBitrate < 0.08) return;
|
|
||||||
if (!force && performance.now() - lastBrReconfAt < 800) return;
|
|
||||||
activeBitrate = target;
|
|
||||||
lastBrReconfAt = performance.now();
|
|
||||||
try {
|
|
||||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
const encoder = new VideoEncoder({
|
const encoder = new VideoEncoder({
|
||||||
output: async (chunk) => {
|
output: async (chunk) => {
|
||||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
|
|
@ -2859,9 +2713,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
currentVoiceWs.send(payload.buffer);
|
currentVoiceWs.send(payload.buffer);
|
||||||
}, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); }
|
}, error: (e) => { stopFunction(); }
|
||||||
});
|
});
|
||||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
|
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||||
|
|
||||||
// Transient capture-mode wobbles must not thrash the encoder -
|
// Transient capture-mode wobbles must not thrash the encoder -
|
||||||
// commit only after ~300 ms stable. Camera size/fps changes arrive
|
// commit only after ~300 ms stable. Camera size/fps changes arrive
|
||||||
|
|
@ -2879,62 +2733,20 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
pendingCfg = newConfig;
|
pendingCfg = newConfig;
|
||||||
pendingAt = performance.now();
|
pendingAt = performance.now();
|
||||||
} else if (performance.now() - pendingAt > 300) {
|
} else if (performance.now() - pendingAt > 300) {
|
||||||
//Zmiana geometrii źródła - uczony scale łącza zostaje,
|
currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null;
|
||||||
//resetujemy tylko redukcję FPS
|
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||||
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
|
|
||||||
activeBitrate = Math.round(currentConfig.bitrate * brScale);
|
|
||||||
fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
|
|
||||||
applyEncoderBitrate(true);
|
|
||||||
}
|
}
|
||||||
} else if (pendingCfg) {
|
} else if (pendingCfg) {
|
||||||
pendingCfg = null; // wiggle reverted on its own - ignore
|
pendingCfg = null; // wiggle reverted on its own - ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
//Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio
|
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) {
|
||||||
//(14 KB) - przy korku najpierw znika obraz, głos płynie dalej.
|
|
||||||
tickCount++;
|
|
||||||
const policySkip = fpsDiv > 1 && (tickCount % fpsDiv !== 0);
|
|
||||||
let skipped = false;
|
|
||||||
if (policySkip) {
|
|
||||||
//celowe tempo (fpsDiv) - nie liczy się do skipRatio
|
|
||||||
} else if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 &&
|
|
||||||
currentVoiceWs.readyState === WebSocket.OPEN &&
|
|
||||||
currentVoiceWs.bufferedAmount < VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES && videoEl.readyState >= 2) {
|
|
||||||
let frame = null;
|
let frame = null;
|
||||||
try {
|
try {
|
||||||
const nowMs = performance.now();
|
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
|
||||||
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
|
frameCount++;
|
||||||
//Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija)
|
encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
|
||||||
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
|
|
||||||
encoder.encode(frame, {keyFrame: wantKeyframe});
|
|
||||||
if (wantKeyframe) lastKeyframeAt = nowMs;
|
|
||||||
} catch (err) { } finally { if (frame) frame.close(); }
|
} catch (err) { } finally { if (frame) frame.close(); }
|
||||||
} else if (encoder.state === 'configured') {
|
|
||||||
skipped = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
encTries++;
|
|
||||||
if (skipped) encSkips++;
|
|
||||||
const winNow = performance.now();
|
|
||||||
if (winNow - brWindowAt >= 800) {
|
|
||||||
const skipRatio = encTries > 0 ? encSkips / encTries : 0;
|
|
||||||
const congestionMs = voiceCongestionDelay();
|
|
||||||
if (congestionMs > 450 || skipRatio > 0.3 || congestionMs > 250) {
|
|
||||||
brScale = Math.max(0.15, brScale * (congestionMs > 450 ? 0.6 : 0.75));
|
|
||||||
applyEncoderBitrate();
|
|
||||||
if (brScale <= 0.17) lowStreak++; else lowStreak = 0;
|
|
||||||
if (lowStreak >= 4 && fpsDiv < 3) { fpsDiv++; lowStreak = 0; }
|
|
||||||
cleanStreak = 0;
|
|
||||||
} else if (skipRatio === 0 && congestionMs < 80) {
|
|
||||||
cleanStreak++;
|
|
||||||
lowStreak = 0;
|
|
||||||
if (cleanStreak >= 5) {
|
|
||||||
if (fpsDiv > 1) { fpsDiv--; }
|
|
||||||
else if (brScale < 1) { brScale = Math.min(1, brScale * 1.2); applyEncoderBitrate(); }
|
|
||||||
cleanStreak = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encTries = 0; encSkips = 0; brWindowAt = winNow;
|
|
||||||
}
|
}
|
||||||
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4206,55 +4206,6 @@ function handleChatInputKey(event) {
|
||||||
let dmMessagePollInterval = null;
|
let dmMessagePollInterval = null;
|
||||||
let appWebSocket = null;
|
let appWebSocket = null;
|
||||||
let dmMessageLoadTimeout = null;
|
let dmMessageLoadTimeout = null;
|
||||||
let appWsPingTimer = null;
|
|
||||||
let callStatesResyncTimer = null;
|
|
||||||
|
|
||||||
//Keepalive app-WS: tekstowy "ping" co 20 s. Serwer zamyka połączenie po 75 s
|
|
||||||
//bezczynności (AppWsIdleTimeout) - bez tego half-open TCP po zabiciu aplikacji /
|
|
||||||
//zmianie sieci wisiał na serwerze godzinami, cleanup ActiveCalls nie biegł i
|
|
||||||
//wskaźniki rozmów pokazywały uczestników, którzy dawno wyszli. Ping utrzymuje
|
|
||||||
//też mapping NAT przy danych komórkowych, więc eventy (call_update!) nie giną.
|
|
||||||
function startAppWsPing() {
|
|
||||||
if (appWsPingTimer) clearInterval(appWsPingTimer);
|
|
||||||
appWsPingTimer = setInterval(() => {
|
|
||||||
if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) {
|
|
||||||
try { appWebSocket.send("ping"); } catch (e) {}
|
|
||||||
}
|
|
||||||
}, 20000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAppWsPing() {
|
|
||||||
if (appWsPingTimer) { clearInterval(appWsPingTimer); appWsPingTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
//Pełna resynchronizacja wskaźników rozmów: zgubiony podczas przerwy w app-WS
|
|
||||||
//call_update NIGDY nie jest odtwarzany przez serwer, a seedCallStateFromList
|
|
||||||
//tylko dodaje wpisy - bez tego wskaźniki "znikały dopiero po ręcznym odświeżeniu".
|
|
||||||
//Dla każdego pokoju znanego jako aktywny (+ otwartego) pyta room/voice/state
|
|
||||||
//i koryguje mapę oraz UI. Uruchamiane: po (re)otwarciu WS, co 30 s, przy powrocie
|
|
||||||
//karty/aplikacji na wierzch.
|
|
||||||
async function resyncAllCallStates() {
|
|
||||||
if (!window.id || typeof fetchEncrypted !== "function" || typeof ensureActiveCallsMap !== "function") return;
|
|
||||||
let map = ensureActiveCallsMap();
|
|
||||||
let keys = Object.keys(map);
|
|
||||||
let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
|
|
||||||
if (currentKey && !keys.includes(currentKey)) keys.push(currentKey);
|
|
||||||
if (keys.length === 0) return;
|
|
||||||
|
|
||||||
for (let key of keys) {
|
|
||||||
let roomType = window.currentDmId === key && window.currentRoomType ? window.currentRoomType
|
|
||||||
: (String(key).includes("_") ? "dms" : "groups");
|
|
||||||
try {
|
|
||||||
let res = await fetchEncrypted("room/voice/state", JSON.stringify({ string1: roomType, string2: key }));
|
|
||||||
if (!res || !res.startsWith("[")) continue;
|
|
||||||
let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
|
|
||||||
if (participants.length > 0) map[key] = participants;
|
|
||||||
else delete map[key];
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
//Po serii poprawek pełne przerysowanie wskaźników (listy + top-bary + details)
|
|
||||||
if (typeof updateVoicePresenceUI === "function") updateVoicePresenceUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupWebSocket() {
|
function setupWebSocket() {
|
||||||
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
|
@ -4277,17 +4228,6 @@ function setupWebSocket() {
|
||||||
} finally {
|
} finally {
|
||||||
release();
|
release();
|
||||||
}
|
}
|
||||||
|
|
||||||
startAppWsPing();
|
|
||||||
//Po każdym (re)połączeniu odtwarzamy stan wskaźników - eventy call_update
|
|
||||||
//z czasu rozłączenia są bezpowrotnie stracone
|
|
||||||
resyncAllCallStates();
|
|
||||||
if (!callStatesResyncTimer) {
|
|
||||||
callStatesResyncTimer = setInterval(() => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
resyncAllCallStates();
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
appWebSocket.onmessage = (event) => {
|
appWebSocket.onmessage = (event) => {
|
||||||
|
|
@ -4333,22 +4273,10 @@ function setupWebSocket() {
|
||||||
};
|
};
|
||||||
|
|
||||||
appWebSocket.onclose = () => {
|
appWebSocket.onclose = () => {
|
||||||
stopAppWsPing();
|
|
||||||
setTimeout(setupWebSocket, 5000);
|
setTimeout(setupWebSocket, 5000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
//Powrót karty/aplikacji na wierzch: mobilne WebView potrafią trzymać martwy
|
|
||||||
//socket odkryty bardzo długo (brak ramek = brak detekcji). Wymuszamy próbę
|
|
||||||
//połączenia i od razu resynchronizujemy wskaźniki rozmów.
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
if (appWebSocket && appWebSocket.readyState !== WebSocket.OPEN && appWebSocket.readyState !== WebSocket.CONNECTING) {
|
|
||||||
setupWebSocket();
|
|
||||||
}
|
|
||||||
resyncAllCallStates();
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentContextMenuMsgId = null;
|
let currentContextMenuMsgId = null;
|
||||||
let touchContextMenuFired = false;
|
let touchContextMenuFired = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ var invitesEntry = `
|
||||||
|
|
||||||
var chatScreen = `
|
var chatScreen = `
|
||||||
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
|
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
|
||||||
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; overflow-x: hidden; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
</div>
|
</div>
|
||||||
<div id="replying-bar" style="display: none; padding: 0 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
|
<div id="replying-bar" style="display: none; padding: 0 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
|
||||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
|
||||||
|
|
|
||||||
|
|
@ -686,7 +686,6 @@ space{
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
.room-pfp {
|
.room-pfp {
|
||||||
width: 2.2rem;
|
width: 2.2rem;
|
||||||
|
|
@ -739,7 +738,6 @@ space{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.chat-message-header {
|
.chat-message-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1180,15 +1178,7 @@ input:checked + .toggle-slider:before {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Czerwona kropka nieprzeczytanych - pozycjonowana absolutnie. Odstęp liczony tak,
|
|
||||||
żeby ŚRODEK kropki leżał na tej samej osi co środek 1rem-owego .call-indicator
|
|
||||||
(0.6rem paddingu + (1rem - 0.65rem)/2) - inaczej kropka "ucieka" w prawo
|
|
||||||
względem większej ikony słuchawek. */
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
position: absolute;
|
|
||||||
right: calc(0.6rem + (1rem - 0.65rem) / 2);
|
|
||||||
top: 50%;
|
|
||||||
margin-top: -0.325rem;
|
|
||||||
width: 0.65rem;
|
width: 0.65rem;
|
||||||
height: 0.65rem;
|
height: 0.65rem;
|
||||||
min-width: 0.65rem;
|
min-width: 0.65rem;
|
||||||
|
|
@ -1196,6 +1186,7 @@ input:checked + .toggle-slider:before {
|
||||||
background-color: var(--big-red);
|
background-color: var(--big-red);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
webroot/voice.js
334
webroot/voice.js
|
|
@ -1,6 +1,7 @@
|
||||||
let currentVoiceWs = null;
|
let currentVoiceWs = null;
|
||||||
let currentVoiceCallId = null;
|
let currentVoiceCallId = null;
|
||||||
let currentVoiceCallType = null;
|
let currentVoiceCallType = null;
|
||||||
|
let voiceWsHeartbeat = null;
|
||||||
let voiceActiveUsers = [];
|
let voiceActiveUsers = [];
|
||||||
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
|
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
|
||||||
let voiceUiContainer = null;
|
let voiceUiContainer = null;
|
||||||
|
|
@ -106,8 +107,8 @@ function getVoicePeerSenderKey(clientIdStr) {
|
||||||
|
|
||||||
|
|
||||||
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
|
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
|
||||||
let micSourceNode = null, senderNode = null, receiverNode = null;
|
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
|
||||||
let remoteAudio = {}, audioDecryptChains = {};
|
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
|
||||||
let knownVoiceUsers = new Set();
|
let knownVoiceUsers = new Set();
|
||||||
let isMicMuted = false;
|
let isMicMuted = false;
|
||||||
let remoteVideos = {}, videoDecryptChains = {};
|
let remoteVideos = {}, videoDecryptChains = {};
|
||||||
|
|
@ -125,26 +126,22 @@ let isVoiceSpeakerMode = false;
|
||||||
|
|
||||||
const SPEAKING_HANGOVER_MS = 400;
|
const SPEAKING_HANGOVER_MS = 400;
|
||||||
|
|
||||||
//Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione,
|
// ===== Voice connection resilience =====
|
||||||
//więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy
|
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
|
||||||
//14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej.
|
// user" apart from "crashed/half-open socket". Peers silent for too long are
|
||||||
const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024;
|
// dropped locally; a dead server socket triggers an automatic rejoin.
|
||||||
const VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES = 14 * 1024;
|
|
||||||
|
|
||||||
//Resilience: peer bez pakietów >7 s = lokalnie martwy (media wycięte), socket
|
|
||||||
//bez ruchu od ŻYWYCH peerów >15 s = wymuszony reconnect. Pings co 2 s.
|
|
||||||
const VOICE_PEER_TIMEOUT_MS = 7000;
|
const VOICE_PEER_TIMEOUT_MS = 7000;
|
||||||
const VOICE_PING_INTERVAL_MS = 2000;
|
const VOICE_PING_INTERVAL_MS = 2000;
|
||||||
const VOICE_SERVER_TIMEOUT_MS = 15000;
|
const VOICE_SERVER_TIMEOUT_MS = 10000;
|
||||||
const VOICE_MAX_RECONNECTS = 4;
|
const VOICE_MAX_RECONNECTS = 2;
|
||||||
const VOICE_ROSTER_POLL_MS = 5000;
|
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
let lastVoiceServerPacketAt = 0;
|
||||||
|
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
|
||||||
|
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
|
||||||
let voiceHadRemotePeers = false;
|
let voiceHadRemotePeers = false;
|
||||||
let voicePingTimer = null;
|
let voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let voiceRosterPollTimer = null;
|
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
let voiceReconnectAttempts = 0;
|
let voiceReconnectAttempts = 0;
|
||||||
let voiceSocketOpenedAt = 0;
|
let voiceSocketOpenedAt = 0;
|
||||||
|
|
@ -170,21 +167,11 @@ function cleanupPeerMedia(userId) {
|
||||||
function sendVoicePing() {
|
function sendVoicePing() {
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
//Typ 5: prośba o echo. Payload = 8B timestamp (ms, WŁASNY zegar). Odbiorca
|
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
|
||||||
//odsyła te same bajty jako typ 6, a RTT liczę dopiero na jego powrocie.
|
|
||||||
const tsData = BigInt(Date.now());
|
|
||||||
const tsBuf = new ArrayBuffer(8);
|
|
||||||
new DataView(tsBuf).setBigUint64(0, tsData);
|
|
||||||
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array(tsBuf)).then(cipher => {
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
currentVoiceWs.send(payload.buffer);
|
currentVoiceWs.send(payload.buffer);
|
||||||
voiceOutstandingPings.set(tsData, Date.now());
|
|
||||||
const nowMs = Date.now();
|
|
||||||
for (const [k, sentAt] of voiceOutstandingPings) {
|
|
||||||
if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,15 +217,9 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
|
||||||
let voiceSettings = loadVoiceSettings();
|
let voiceSettings = loadVoiceSettings();
|
||||||
|
|
||||||
function loadVoiceSettings() {
|
function loadVoiceSettings() {
|
||||||
//Globalny default 480p@24; stare zapisane defaulty (720p/1080p/30fps)
|
const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
|
||||||
//migrują na nowe, świadome wybory użytkownika zostają.
|
|
||||||
const defaults = { micId: "", camId: "", speakerId: "", camRes: "640x480", camFps: 24, screenRes: "640x480", screenFps: 24, sensitivity: VOICE_SENSITIVITY_DEFAULT };
|
|
||||||
try {
|
try {
|
||||||
let s = Object.assign({}, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
|
return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
|
||||||
if (s.camRes === "1280x720") s.camRes = defaults.camRes;
|
|
||||||
if (s.screenRes === "1920x1080") s.screenRes = defaults.screenRes;
|
|
||||||
if (s.screenFps === 30 || s.screenFps === "30") s.screenFps = defaults.screenFps;
|
|
||||||
return Object.assign(defaults, s);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
@ -293,35 +274,6 @@ async function applyVoiceSpeakerSink() {
|
||||||
let voiceConnectedAt = null;
|
let voiceConnectedAt = null;
|
||||||
let voiceTimerInterval = null;
|
let voiceTimerInterval = null;
|
||||||
let voicePingMs = null;
|
let voicePingMs = null;
|
||||||
//Kontrola congestionu: bufferedAmount nie widzi backlogu kernela TCP, prawdziwy
|
|
||||||
//obraz daje RTT z pingów. Baseline = MINIMUM z okna próbek (odporne na skoki
|
|
||||||
//i na peera sztucznie opóźniającego własne echa - stałe kłamstwo podnosi minimum).
|
|
||||||
let voiceRttFastMs = 0;
|
|
||||||
let voiceRttSamples = [];
|
|
||||||
let voiceRttBaseMs = 0;
|
|
||||||
let voiceAudioHoldUntil = 0; //histereza zaworu audio
|
|
||||||
|
|
||||||
//Echo-ping (typ 5 = prośba, typ 6 = echo): RTT wyłącznie na WŁASNYM zegarze -
|
|
||||||
//wysyłam timestamp, peer odbija te same bajty, odejmuję czas nadania. Zegar
|
|
||||||
//peera nigdy nie jest używany (może być przekręcony lub spreparowany).
|
|
||||||
const voiceOutstandingPings = new Map(); // BigInt(ts) -> czas nadania
|
|
||||||
let voiceLastEchoAt = 0; // rate-limit odpowiedzi echem
|
|
||||||
let voiceLastRttSampleAt = 0; // kiedy ostatnio spłynął prawdziwy pomiar
|
|
||||||
|
|
||||||
function voiceCongestionDelay() {
|
|
||||||
if (!voiceRttFastMs || !voiceRttBaseMs) return 0;
|
|
||||||
return Math.min(5000, Math.max(0, voiceRttFastMs - voiceRttBaseMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
function voiceRecordRttSample(rtt) {
|
|
||||||
voiceLastRttSampleAt = Date.now();
|
|
||||||
voicePingMs = voicePingMs === null ? rtt : Math.round(voicePingMs * 0.7 + rtt * 0.3);
|
|
||||||
voiceRttFastMs = voiceRttFastMs ? Math.round(voiceRttFastMs * 0.5 + rtt * 0.5) : rtt;
|
|
||||||
voiceRttSamples.push(rtt);
|
|
||||||
if (voiceRttSamples.length > 12) voiceRttSamples.shift();
|
|
||||||
voiceRttBaseMs = Math.min.apply(null, voiceRttSamples);
|
|
||||||
updateVoicePing();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVoiceDuration(ms) {
|
function formatVoiceDuration(ms) {
|
||||||
let totalSec = Math.floor(ms / 1000);
|
let totalSec = Math.floor(ms / 1000);
|
||||||
|
|
@ -678,11 +630,7 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
//deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP
|
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||||
//jeszcze nie zsynchronizowany przez serwer) nigdy nic nie wyśle, więc
|
|
||||||
//zliczany jak żywy wymuszał reconnecty w pętli - migające "Connecting"
|
|
||||||
//w zdrowej rozmowie.
|
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id) && !deadPeers.has(u)).length;
|
|
||||||
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
|
||||||
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
|
||||||
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
|
||||||
|
|
@ -745,8 +693,8 @@ class AudioReceiver extends AudioWorkletProcessor {
|
||||||
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
||||||
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
||||||
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
||||||
// Backlog above target is drained adaptively (proportional to excess, see
|
// Backlog slightly above target is drained by a gentle trim (1 sample per
|
||||||
// process()) so latency returns to ~43 ms quickly after network bursts.
|
// quantum) instead of letting latency creep up to the hard limit.
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super();
|
super();
|
||||||
this.users = {};
|
this.users = {};
|
||||||
|
|
@ -784,12 +732,7 @@ class AudioReceiver extends AudioWorkletProcessor {
|
||||||
if (available < output.length) { user.buffering = true; }
|
if (available < output.length) { user.buffering = true; }
|
||||||
else {
|
else {
|
||||||
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
||||||
else if (available > 2049) {
|
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
|
||||||
//Adaptacyjny drain: nadmiar nad target odprowadzany proporcjonalnie
|
|
||||||
//(1 + excess/256 /kwant) - powrót z ~128 ms do ~43 ms w ułamku sekundy
|
|
||||||
const excess = available - 2048;
|
|
||||||
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536;
|
|
||||||
}
|
|
||||||
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
|
let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
|
||||||
for (let i = 0; i < output.length; i++) {
|
for (let i = 0; i < output.length; i++) {
|
||||||
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
|
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
|
||||||
|
|
@ -947,38 +890,6 @@ function bytesToB64u(a) {
|
||||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
//Roster pokoju z voice-servera (źródło prawdy) -> synchronizacja kafelków.
|
|
||||||
//Steady-poll co VOICE_ROSTER_POLL_MS - bez tego sweep ghosta/leave bez
|
|
||||||
//call_update nigdy nie docierał do UI.
|
|
||||||
async function fetchVoiceRoster() {
|
|
||||||
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=${encodeURIComponent(currentVoiceCallId)}`);
|
|
||||||
const usersRaw = await res.json();
|
|
||||||
//HTTP tylko jako szacunek gdy brak prawdziwych próbek RTT (solo / stary peer)
|
|
||||||
if (voicePingMs === null || Date.now() - voiceLastRttSampleAt > 25000) {
|
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
|
||||||
updateVoicePing();
|
|
||||||
}
|
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
|
||||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
|
||||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
|
||||||
for (const gone of [...deadPeers]) {
|
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
|
||||||
}
|
|
||||||
const users = rawUsers;
|
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
|
||||||
if(changed) {
|
|
||||||
voiceActiveUsers = users;
|
|
||||||
updateVoiceUIUsers();
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startVoiceSession(serverUrl, ticket) {
|
async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (!ticket) throw new Error("missing voice ticket");
|
if (!ticket) throw new Error("missing voice ticket");
|
||||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||||
|
|
@ -1021,7 +932,32 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
else btn.classList.remove("voice-btn-danger");
|
else btn.classList.remove("voice-btn-danger");
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchVoiceRoster();
|
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));
|
||||||
|
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||||
|
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||||
|
for (const gone of [...deadPeers]) {
|
||||||
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
|
}
|
||||||
|
const users = rawUsers;
|
||||||
|
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();
|
||||||
//The voice-server roster is joined only AFTER ticket validation (an
|
//The voice-server roster is joined only AFTER ticket validation (an
|
||||||
//HTTP roundtrip to the main server), so the immediate fetch can still
|
//HTTP roundtrip to the main server), so the immediate fetch can still
|
||||||
//miss us - retry quickly until our own tile shows up instead of
|
//miss us - retry quickly until our own tile shows up instead of
|
||||||
|
|
@ -1035,17 +971,13 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voiceRosterRetryTimer = null;
|
voiceRosterRetryTimer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchVoiceRoster();
|
fetchUsers();
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
voiceReconnectAttempts = 0;
|
voiceReconnectAttempts = 0;
|
||||||
lastVoiceServerPacketAt = Date.now();
|
lastVoiceServerPacketAt = Date.now();
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||||
//Steady roster poll - utrzymuje kafelki zgodne z prawdą serwera
|
|
||||||
//(ghost-sweep, leave bez call_update) przez całą rozmowę.
|
|
||||||
if (voiceRosterPollTimer) clearInterval(voiceRosterPollTimer);
|
|
||||||
voiceRosterPollTimer = setInterval(fetchVoiceRoster, VOICE_ROSTER_POLL_MS);
|
|
||||||
|
|
||||||
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||||
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||||
|
|
@ -1101,18 +1033,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
const myIdStr = String(window.id);
|
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||||
//Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla
|
// Backpressure valve: when the socket is congested, drop mic
|
||||||
//bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej
|
// frames instead of queueing them - queued audio would arrive
|
||||||
//urwać ułamek sekundy niż mówić z sekundami opóźnienia.
|
// minutes late and drown the connection for every stream.
|
||||||
if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) {
|
if (currentVoiceWs.bufferedAmount > 256 * 1024) {
|
||||||
vadHangover = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700;
|
|
||||||
if (Date.now() < voiceAudioHoldUntil) {
|
|
||||||
vadHangover = 0;
|
vadHangover = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1127,7 +1053,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
|
|
||||||
if (db >= sensitivity) vadHangover = 75;
|
if (db >= sensitivity) vadHangover = 75;
|
||||||
let isSpeaking = vadHangover > 0 && !isMicMuted;
|
let isSpeaking = vadHangover > 0 && !isMicMuted;
|
||||||
setTileSpeaking(myIdStr, isSpeaking);
|
setTileSpeaking(String(window.id), isSpeaking);
|
||||||
|
|
||||||
if (vadHangover > 0 && !isMicMuted) {
|
if (vadHangover > 0 && !isMicMuted) {
|
||||||
vadHangover--;
|
vadHangover--;
|
||||||
|
|
@ -1158,44 +1084,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
|
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
|
||||||
|
|
||||||
const type = view.getUint8(8);
|
const type = view.getUint8(8);
|
||||||
if (type === 5 || type === 6) {
|
if (type === 5) return; // liveness ping - presence already recorded
|
||||||
//Liveness już zapisany wyżej. Dalej tylko przy zainstalowanym kluczu.
|
|
||||||
if (voiceAesKeyBytes) {
|
|
||||||
const senderKey = getVoicePeerSenderKey(clientId);
|
|
||||||
if (senderKey) {
|
|
||||||
//Offsety ODBIORCZE: serwer dokleja 8B nadawcy, więc iv=[10..22),
|
|
||||||
//ct=[22..). Format wysyłki (iv=2, ct=14) jest INNY - pomylenie
|
|
||||||
//psuje każdą deszyfrację cicho (GCM auth fail -> .catch)
|
|
||||||
const piv = new Uint8Array(arrayBuffer, 10, 12);
|
|
||||||
const pct = new Uint8Array(arrayBuffer, 22);
|
|
||||||
crypto.subtle.decrypt({name: "AES-GCM", iv: piv}, senderKey, pct).then(plain => {
|
|
||||||
if (plain.byteLength !== 8) return; //stary klient: ping bez timestampu
|
|
||||||
const tsData = new DataView(plain).getBigUint64(0);
|
|
||||||
if (type === 5) {
|
|
||||||
//Odbij te same bajty własnym podkluczem i świeżym IV; rate-limit
|
|
||||||
const nowMs = Date.now();
|
|
||||||
if (nowMs - voiceLastEchoAt < 150) return;
|
|
||||||
voiceLastEchoAt = nowMs;
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
|
||||||
const riv = nextVoiceIv();
|
|
||||||
crypto.subtle.encrypt({name: "AES-GCM", iv: riv}, voiceMySenderCryptoKey, plain).then(rcipher => {
|
|
||||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
|
||||||
const rpayload = new Uint8Array(14 + rcipher.byteLength);
|
|
||||||
rpayload[0] = 6; rpayload[1] = 0; rpayload.set(riv, 2); rpayload.set(new Uint8Array(rcipher), 14);
|
|
||||||
currentVoiceWs.send(rpayload.buffer);
|
|
||||||
}).catch(() => {});
|
|
||||||
} else {
|
|
||||||
//Typ 6: powrót własnego timestampu - RTT na WŁASNYM zegarze.
|
|
||||||
if (!voiceOutstandingPings.has(tsData)) return;
|
|
||||||
voiceOutstandingPings.delete(tsData);
|
|
||||||
const rtt = Date.now() - Number(tsData);
|
|
||||||
if (rtt >= 1 && rtt < 15000) voiceRecordRttSample(rtt);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
|
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
|
|
@ -1276,7 +1165,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
frame.close();
|
frame.close();
|
||||||
},
|
},
|
||||||
error: e => {
|
error: e => {
|
||||||
voiceLogError('videodecoder[' + decoderId + ']', e);
|
|
||||||
if (remoteVideos[decoderId]) {
|
if (remoteVideos[decoderId]) {
|
||||||
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
||||||
remoteVideos[decoderId].decoder = null;
|
remoteVideos[decoderId].decoder = null;
|
||||||
|
|
@ -1302,14 +1190,11 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
try {
|
try {
|
||||||
streamData.decoder.decode(chunk);
|
streamData.decoder.decode(chunk);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
voiceLogError('videodecode[' + decoderId + ']', err);
|
|
||||||
try { streamData.decoder.close(); } catch(e){}
|
try { streamData.decoder.close(); } catch(e){}
|
||||||
streamData.decoder = null;
|
streamData.decoder = null;
|
||||||
streamData.hasKeyFrame = false;
|
streamData.hasKeyFrame = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
voiceLogError('vdecrypt[' + decoderId + ']', err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -1447,6 +1332,8 @@ async function handleCallUpdate(data) {
|
||||||
for (let u in remoteAudio) {
|
for (let u in remoteAudio) {
|
||||||
let pureU = u.replace("_screen", "");
|
let pureU = u.replace("_screen", "");
|
||||||
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
|
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
|
||||||
|
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
|
||||||
|
delete audioDecoders[u];
|
||||||
delete remoteAudio[u];
|
delete remoteAudio[u];
|
||||||
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
|
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
|
||||||
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
|
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
|
||||||
|
|
@ -2603,6 +2490,7 @@ function toggleVoiceMic() {
|
||||||
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
|
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
|
||||||
function teardownVoiceTransport() {
|
function teardownVoiceTransport() {
|
||||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||||
|
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||||
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
|
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
|
||||||
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
|
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
|
||||||
if (currentVoiceWs) {
|
if (currentVoiceWs) {
|
||||||
|
|
@ -2619,7 +2507,6 @@ function teardownVoiceTransport() {
|
||||||
peerLastSeen = {};
|
peerLastSeen = {};
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
//Stale participants from the previous attempt must not linger in the UI
|
//Stale participants from the previous attempt must not linger in the UI
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
|
|
@ -2629,12 +2516,6 @@ function teardownVoiceTransport() {
|
||||||
setVoiceRootKey(null);
|
setVoiceRootKey(null);
|
||||||
voiceSendEpoch = null;
|
voiceSendEpoch = null;
|
||||||
voiceSendCounter = 0n;
|
voiceSendCounter = 0n;
|
||||||
voiceRttFastMs = 0;
|
|
||||||
voiceRttSamples = [];
|
|
||||||
voiceRttBaseMs = 0;
|
|
||||||
voiceAudioHoldUntil = 0;
|
|
||||||
voiceOutstandingPings.clear();
|
|
||||||
voiceLastRttSampleAt = 0;
|
|
||||||
lastVoiceServerPacketAt = Date.now();
|
lastVoiceServerPacketAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2651,10 +2532,6 @@ function scheduleVoiceReconnect() {
|
||||||
voiceReconnectAttempts++;
|
voiceReconnectAttempts++;
|
||||||
setVoiceStatus("connecting");
|
setVoiceStatus("connecting");
|
||||||
teardownVoiceTransport();
|
teardownVoiceTransport();
|
||||||
//Zmiana sieci = stary RTT dotyczy martwej ścieżki; bez resetu wskaźnik
|
|
||||||
//wisiałby zamrożony, a fallback HTTP nie miał prawa go nadpisać
|
|
||||||
voicePingMs = null;
|
|
||||||
updateVoicePing();
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
if (!currentVoiceCallId || !currentVoiceCallType) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2666,7 +2543,7 @@ function scheduleVoiceReconnect() {
|
||||||
console.warn("Voice reconnect failed", err);
|
console.warn("Voice reconnect failed", err);
|
||||||
scheduleVoiceReconnect();
|
scheduleVoiceReconnect();
|
||||||
}
|
}
|
||||||
}, Math.min(4000, 800 * voiceReconnectAttempts));
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
function endVoiceSession() {
|
function endVoiceSession() {
|
||||||
|
|
@ -2706,27 +2583,24 @@ function endVoiceSession() {
|
||||||
micStream = null;
|
micStream = null;
|
||||||
}
|
}
|
||||||
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
|
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
|
||||||
|
if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
|
||||||
|
|
||||||
|
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
|
||||||
peerLastSeen = {};
|
peerLastSeen = {};
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceReconnectAttempts = 0;
|
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
voiceHadRemotePeers = false;
|
voiceHadRemotePeers = false;
|
||||||
voiceRttFastMs = 0;
|
|
||||||
voiceRttSamples = [];
|
|
||||||
voiceRttBaseMs = 0;
|
|
||||||
voiceAudioHoldUntil = 0;
|
|
||||||
voiceOutstandingPings.clear();
|
|
||||||
voiceLastEchoAt = 0;
|
|
||||||
voiceLastRttSampleAt = 0;
|
|
||||||
|
|
||||||
|
for (let id in audioDecoders) {
|
||||||
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
}
|
||||||
for (let id in audioDecryptChains) {
|
for (let id in audioDecryptChains) {
|
||||||
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
|
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
|
||||||
}
|
}
|
||||||
|
audioDecoders = {};
|
||||||
audioDecryptChains = {};
|
audioDecryptChains = {};
|
||||||
remoteAudio = {};
|
remoteAudio = {};
|
||||||
|
|
||||||
|
|
@ -2798,11 +2672,8 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||||
screenSenderNode.port.onmessage = async (e) => {
|
screenSenderNode.port.onmessage = async (e) => {
|
||||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
// Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio
|
// Same backpressure valve as the mic sender.
|
||||||
// ma pierwszeństwo przed wideo na wspólnym sockecie.
|
if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
|
||||||
if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) return;
|
|
||||||
if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700;
|
|
||||||
if (Date.now() < voiceAudioHoldUntil) return;
|
|
||||||
const iv = nextVoiceIv();
|
const iv = nextVoiceIv();
|
||||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
|
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
|
|
@ -2832,24 +2703,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
return { width, height, fps, bitrate };
|
return { width, height, fps, bitrate };
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0;
|
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||||
//AIMD + slow-start: start na 40% celu (pełną prędkością waliłby w nieznany
|
|
||||||
//uplink, a bufory TCP w kernelu to MB-y = sekundy kolejki zanim cokolwiek
|
|
||||||
//zmierzymy). Sygnał korka: skipy (zawór 12 KB) lub nadwyżka RTT.
|
|
||||||
let brScale = 0.4, encTries = 0, encSkips = 0, brWindowAt = performance.now(), lastBrReconfAt = 0;
|
|
||||||
//Korek trwa mimo podłogi bitrate'u → FPS spada do 1/2, 1/3
|
|
||||||
let fpsDiv = 1, lowStreak = 0, cleanStreak = 0, tickCount = 0;
|
|
||||||
let activeBitrate = Math.round(currentConfig.bitrate * brScale);
|
|
||||||
const applyEncoderBitrate = (force = false) => {
|
|
||||||
const target = Math.max(120000, Math.round(currentConfig.bitrate * brScale));
|
|
||||||
if (!force && Math.abs(target - activeBitrate) / activeBitrate < 0.08) return;
|
|
||||||
if (!force && performance.now() - lastBrReconfAt < 800) return;
|
|
||||||
activeBitrate = target;
|
|
||||||
lastBrReconfAt = performance.now();
|
|
||||||
try {
|
|
||||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
const encoder = new VideoEncoder({
|
const encoder = new VideoEncoder({
|
||||||
output: async (chunk) => {
|
output: async (chunk) => {
|
||||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||||
|
|
@ -2859,9 +2713,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||||
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||||
currentVoiceWs.send(payload.buffer);
|
currentVoiceWs.send(payload.buffer);
|
||||||
}, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); }
|
}, error: (e) => { stopFunction(); }
|
||||||
});
|
});
|
||||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
|
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||||
|
|
||||||
// Transient capture-mode wobbles must not thrash the encoder -
|
// Transient capture-mode wobbles must not thrash the encoder -
|
||||||
// commit only after ~300 ms stable. Camera size/fps changes arrive
|
// commit only after ~300 ms stable. Camera size/fps changes arrive
|
||||||
|
|
@ -2879,62 +2733,20 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
||||||
pendingCfg = newConfig;
|
pendingCfg = newConfig;
|
||||||
pendingAt = performance.now();
|
pendingAt = performance.now();
|
||||||
} else if (performance.now() - pendingAt > 300) {
|
} else if (performance.now() - pendingAt > 300) {
|
||||||
//Zmiana geometrii źródła - uczony scale łącza zostaje,
|
currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null;
|
||||||
//resetujemy tylko redukcję FPS
|
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||||
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
|
|
||||||
activeBitrate = Math.round(currentConfig.bitrate * brScale);
|
|
||||||
fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
|
|
||||||
applyEncoderBitrate(true);
|
|
||||||
}
|
}
|
||||||
} else if (pendingCfg) {
|
} else if (pendingCfg) {
|
||||||
pendingCfg = null; // wiggle reverted on its own - ignore
|
pendingCfg = null; // wiggle reverted on its own - ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
//Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio
|
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) {
|
||||||
//(14 KB) - przy korku najpierw znika obraz, głos płynie dalej.
|
|
||||||
tickCount++;
|
|
||||||
const policySkip = fpsDiv > 1 && (tickCount % fpsDiv !== 0);
|
|
||||||
let skipped = false;
|
|
||||||
if (policySkip) {
|
|
||||||
//celowe tempo (fpsDiv) - nie liczy się do skipRatio
|
|
||||||
} else if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 &&
|
|
||||||
currentVoiceWs.readyState === WebSocket.OPEN &&
|
|
||||||
currentVoiceWs.bufferedAmount < VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES && videoEl.readyState >= 2) {
|
|
||||||
let frame = null;
|
let frame = null;
|
||||||
try {
|
try {
|
||||||
const nowMs = performance.now();
|
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
|
||||||
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
|
frameCount++;
|
||||||
//Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija)
|
encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
|
||||||
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
|
|
||||||
encoder.encode(frame, {keyFrame: wantKeyframe});
|
|
||||||
if (wantKeyframe) lastKeyframeAt = nowMs;
|
|
||||||
} catch (err) { } finally { if (frame) frame.close(); }
|
} catch (err) { } finally { if (frame) frame.close(); }
|
||||||
} else if (encoder.state === 'configured') {
|
|
||||||
skipped = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
encTries++;
|
|
||||||
if (skipped) encSkips++;
|
|
||||||
const winNow = performance.now();
|
|
||||||
if (winNow - brWindowAt >= 800) {
|
|
||||||
const skipRatio = encTries > 0 ? encSkips / encTries : 0;
|
|
||||||
const congestionMs = voiceCongestionDelay();
|
|
||||||
if (congestionMs > 450 || skipRatio > 0.3 || congestionMs > 250) {
|
|
||||||
brScale = Math.max(0.15, brScale * (congestionMs > 450 ? 0.6 : 0.75));
|
|
||||||
applyEncoderBitrate();
|
|
||||||
if (brScale <= 0.17) lowStreak++; else lowStreak = 0;
|
|
||||||
if (lowStreak >= 4 && fpsDiv < 3) { fpsDiv++; lowStreak = 0; }
|
|
||||||
cleanStreak = 0;
|
|
||||||
} else if (skipRatio === 0 && congestionMs < 80) {
|
|
||||||
cleanStreak++;
|
|
||||||
lowStreak = 0;
|
|
||||||
if (cleanStreak >= 5) {
|
|
||||||
if (fpsDiv > 1) { fpsDiv--; }
|
|
||||||
else if (brScale < 1) { brScale = Math.min(1, brScale * 1.2); applyEncoderBitrate(); }
|
|
||||||
cleanStreak = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encTries = 0; encSkips = 0; brWindowAt = winNow;
|
|
||||||
}
|
}
|
||||||
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue