From 6a14104143834857af2947c190613ca2e3e9c5a5 Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 26 Aug 2026 09:46:57 +0200 Subject: [PATCH] fix vc desync --- android/app/src/main/assets/public/main.js | 72 +++++++++++ android/app/src/main/assets/public/voice.js | 126 +++++++++++++------- webroot/main.js | 72 +++++++++++ webroot/voice.js | 126 +++++++++++++------- 4 files changed, 314 insertions(+), 82 deletions(-) diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index a5d0ca32..6cf66b66 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -4206,6 +4206,55 @@ function handleChatInputKey(event) { let dmMessagePollInterval = null; let appWebSocket = 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() { if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return; @@ -4228,6 +4277,17 @@ function setupWebSocket() { } finally { 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) => { @@ -4273,10 +4333,22 @@ function setupWebSocket() { }; appWebSocket.onclose = () => { + stopAppWsPing(); 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 touchContextMenuFired = false; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index ea07e4c2..9a17a654 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -130,10 +130,14 @@ const SPEAKING_HANGOVER_MS = 400; // Every client broadcasts a tiny encrypted ping so peers can tell "quiet // user" apart from "crashed/half-open socket". Peers silent for too long are // dropped locally; a dead server socket triggers an automatic rejoin. +// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone): +// ghost z rosteru (martwy TCP po stronie serwera, zamiata go idle-sweeper do +// 15 s) nie może prowokować pętli fałszywych reconnectów = migające "Connecting". const VOICE_PEER_TIMEOUT_MS = 7000; const VOICE_PING_INTERVAL_MS = 2000; -const VOICE_SERVER_TIMEOUT_MS = 10000; -const VOICE_MAX_RECONNECTS = 2; +const VOICE_SERVER_TIMEOUT_MS = 15000; +const VOICE_MAX_RECONNECTS = 4; +const VOICE_ROSTER_POLL_MS = 5000; let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; @@ -142,6 +146,7 @@ let lastVoiceServerPacketAt = 0; let voiceHadRemotePeers = false; let voicePingTimer = null; let voiceRosterRetryTimer = null; +let voiceRosterPollTimer = null; let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -630,7 +635,11 @@ setInterval(() => { } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { - const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; + //deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP + //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 // rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się // nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo. @@ -693,8 +702,8 @@ class AudioReceiver extends AudioWorkletProcessor { // PREBUF_START - initial prebuffer before first playback (~43 ms) // PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms) // HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately - // Backlog slightly above target is drained by a gentle trim (1 sample per - // quantum) instead of letting latency creep up to the hard limit. + // Backlog above target is drained adaptively (proportional to excess, see + // process()) so latency returns to ~43 ms quickly after network bursts. constructor(options) { super(); this.users = {}; @@ -732,7 +741,17 @@ class AudioReceiver extends AudioWorkletProcessor { if (available < output.length) { user.buffering = true; } else { if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536; - else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536; + else if (available > 2049) { + //Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany + //proporcjonalnie (1 + excess/256 próbek na kwant), max ~17 + //= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale + //"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało, + //by po każdym burstu sieciowym latency wisiał pod twardym + //limitem przez kilkanaście sekund - odczuwalne jako "dziwnie + //duże opóźnienie" na sieci komórkowej. + const excess = available - 2048; + user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536; + } let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0; for (let i = 0; i < output.length; i++) { output[i] += (user.buffer[user.readPtr] / 32768) * vol; @@ -890,6 +909,37 @@ function bytesToB64u(a) { return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje +//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia +//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co +//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po +//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków. +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(); + 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) { if (!ticket) throw new Error("missing voice ticket"); // Single-session invariant: a stale socket (e.g. a double-fired join) @@ -932,32 +982,7 @@ async function startVoiceSession(serverUrl, ticket) { else btn.classList.remove("voice-btn-danger"); } - const fetchUsers = async () => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return; - try { - const baseUrl = currentVoiceWs.url.split('/ws')[0]; - const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); - const t0 = performance.now(); - const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`); - const usersRaw = await res.json(); - voicePingMs = Math.round(performance.now() - t0); - updateVoicePing(); - const rawUsers = usersRaw.map(u => String(u)); - //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(); + fetchVoiceRoster(); //The voice-server roster is joined only AFTER ticket validation (an //HTTP roundtrip to the main server), so the immediate fetch can still //miss us - retry quickly until our own tile shows up instead of @@ -971,13 +996,17 @@ async function startVoiceSession(serverUrl, ticket) { voiceRosterRetryTimer = null; return; } - fetchUsers(); + fetchVoiceRoster(); }, 400); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); if (voicePingTimer) clearInterval(voicePingTimer); 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ć //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał @@ -1165,6 +1194,7 @@ async function startVoiceSession(serverUrl, ticket) { frame.close(); }, error: e => { + voiceLogError('videodecoder[' + decoderId + ']', e); if (remoteVideos[decoderId]) { try { remoteVideos[decoderId].decoder.close(); } catch(err) {} remoteVideos[decoderId].decoder = null; @@ -1190,11 +1220,14 @@ async function startVoiceSession(serverUrl, ticket) { try { streamData.decoder.decode(chunk); } catch(err) { + voiceLogError('videodecode[' + decoderId + ']', err); try { streamData.decoder.close(); } catch(e){} streamData.decoder = null; streamData.hasKeyFrame = false; } - } catch (err) {} + } catch (err) { + voiceLogError('vdecrypt[' + decoderId + ']', err); + } }); } else if (type === 3) { (async () => { @@ -2507,6 +2540,7 @@ function teardownVoiceTransport() { peerLastSeen = {}; deadPeers.clear(); if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } lastVoiceKeyRequestAt = 0; //Stale participants from the previous attempt must not linger in the UI voiceActiveUsers = []; @@ -2543,7 +2577,7 @@ function scheduleVoiceReconnect() { console.warn("Voice reconnect failed", err); scheduleVoiceReconnect(); } - }, 800); + }, Math.min(4000, 800 * voiceReconnectAttempts)); } function endVoiceSession() { @@ -2588,9 +2622,11 @@ function endVoiceSession() { if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } peerLastSeen = {}; deadPeers.clear(); lastVoiceKeyRequestAt = 0; + voiceReconnectAttempts = 0; voiceActiveUsers = []; voiceHadRemotePeers = false; @@ -2703,7 +2739,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { return { width, height, fps, bitrate }; }; - let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; + let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0; const encoder = new VideoEncoder({ output: async (chunk) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; @@ -2713,7 +2749,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { 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); currentVoiceWs.send(payload.buffer); - }, error: (e) => { stopFunction(); } + }, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); } }); encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); @@ -2733,7 +2769,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { pendingCfg = newConfig; pendingAt = performance.now(); } else if (performance.now() - pendingAt > 300) { - currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null; + currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null; encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); } } else if (pendingCfg) { @@ -2743,9 +2779,17 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { let frame = null; try { - frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); - frameCount++; - encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0}); + const nowMs = performance.now(); + frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); + //Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych + //klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał + //się z realnym tempem i keyframes stawały się rzadkie. Widz + //dołączający w trakcie udostępniania musi zaczekać na keyframe - + //z licznikiem potrafiło to trwać bardzo długo (efekt "kamery + //nie widać"), z zegarem to maksymalnie ~1 s. + const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); + encoder.encode(frame, {keyFrame: wantKeyframe}); + if (wantKeyframe) lastKeyframeAt = nowMs; } catch (err) { } finally { if (frame) frame.close(); } } setTimeout(encodeLoop, 1000 / currentConfig.fps); diff --git a/webroot/main.js b/webroot/main.js index a5d0ca32..6cf66b66 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -4206,6 +4206,55 @@ function handleChatInputKey(event) { let dmMessagePollInterval = null; let appWebSocket = 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() { if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return; @@ -4228,6 +4277,17 @@ function setupWebSocket() { } finally { 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) => { @@ -4273,10 +4333,22 @@ function setupWebSocket() { }; appWebSocket.onclose = () => { + stopAppWsPing(); 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 touchContextMenuFired = false; diff --git a/webroot/voice.js b/webroot/voice.js index ea07e4c2..9a17a654 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -130,10 +130,14 @@ const SPEAKING_HANGOVER_MS = 400; // Every client broadcasts a tiny encrypted ping so peers can tell "quiet // user" apart from "crashed/half-open socket". Peers silent for too long are // dropped locally; a dead server socket triggers an automatic rejoin. +// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone): +// ghost z rosteru (martwy TCP po stronie serwera, zamiata go idle-sweeper do +// 15 s) nie może prowokować pętli fałszywych reconnectów = migające "Connecting". const VOICE_PEER_TIMEOUT_MS = 7000; const VOICE_PING_INTERVAL_MS = 2000; -const VOICE_SERVER_TIMEOUT_MS = 10000; -const VOICE_MAX_RECONNECTS = 2; +const VOICE_SERVER_TIMEOUT_MS = 15000; +const VOICE_MAX_RECONNECTS = 4; +const VOICE_ROSTER_POLL_MS = 5000; let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; @@ -142,6 +146,7 @@ let lastVoiceServerPacketAt = 0; let voiceHadRemotePeers = false; let voicePingTimer = null; let voiceRosterRetryTimer = null; +let voiceRosterPollTimer = null; let lastVoiceKeyRequestAt = 0; let voiceReconnectAttempts = 0; let voiceSocketOpenedAt = 0; @@ -630,7 +635,11 @@ setInterval(() => { } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { - const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; + //deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP + //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 // rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się // nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo. @@ -693,8 +702,8 @@ class AudioReceiver extends AudioWorkletProcessor { // PREBUF_START - initial prebuffer before first playback (~43 ms) // PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms) // HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately - // Backlog slightly above target is drained by a gentle trim (1 sample per - // quantum) instead of letting latency creep up to the hard limit. + // Backlog above target is drained adaptively (proportional to excess, see + // process()) so latency returns to ~43 ms quickly after network bursts. constructor(options) { super(); this.users = {}; @@ -732,7 +741,17 @@ class AudioReceiver extends AudioWorkletProcessor { if (available < output.length) { user.buffering = true; } else { if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536; - else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536; + else if (available > 2049) { + //Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany + //proporcjonalnie (1 + excess/256 próbek na kwant), max ~17 + //= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale + //"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało, + //by po każdym burstu sieciowym latency wisiał pod twardym + //limitem przez kilkanaście sekund - odczuwalne jako "dziwnie + //duże opóźnienie" na sieci komórkowej. + const excess = available - 2048; + user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536; + } let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0; for (let i = 0; i < output.length; i++) { output[i] += (user.buffer[user.readPtr] / 32768) * vol; @@ -890,6 +909,37 @@ function bytesToB64u(a) { return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje +//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia +//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co +//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po +//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków. +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(); + 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) { if (!ticket) throw new Error("missing voice ticket"); // Single-session invariant: a stale socket (e.g. a double-fired join) @@ -932,32 +982,7 @@ async function startVoiceSession(serverUrl, ticket) { else btn.classList.remove("voice-btn-danger"); } - const fetchUsers = async () => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return; - try { - const baseUrl = currentVoiceWs.url.split('/ws')[0]; - const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); - const t0 = performance.now(); - const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`); - const usersRaw = await res.json(); - voicePingMs = Math.round(performance.now() - t0); - updateVoicePing(); - const rawUsers = usersRaw.map(u => String(u)); - //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(); + fetchVoiceRoster(); //The voice-server roster is joined only AFTER ticket validation (an //HTTP roundtrip to the main server), so the immediate fetch can still //miss us - retry quickly until our own tile shows up instead of @@ -971,13 +996,17 @@ async function startVoiceSession(serverUrl, ticket) { voiceRosterRetryTimer = null; return; } - fetchUsers(); + fetchVoiceRoster(); }, 400); voiceReconnectAttempts = 0; lastVoiceServerPacketAt = Date.now(); if (voicePingTimer) clearInterval(voicePingTimer); 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ć //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał @@ -1165,6 +1194,7 @@ async function startVoiceSession(serverUrl, ticket) { frame.close(); }, error: e => { + voiceLogError('videodecoder[' + decoderId + ']', e); if (remoteVideos[decoderId]) { try { remoteVideos[decoderId].decoder.close(); } catch(err) {} remoteVideos[decoderId].decoder = null; @@ -1190,11 +1220,14 @@ async function startVoiceSession(serverUrl, ticket) { try { streamData.decoder.decode(chunk); } catch(err) { + voiceLogError('videodecode[' + decoderId + ']', err); try { streamData.decoder.close(); } catch(e){} streamData.decoder = null; streamData.hasKeyFrame = false; } - } catch (err) {} + } catch (err) { + voiceLogError('vdecrypt[' + decoderId + ']', err); + } }); } else if (type === 3) { (async () => { @@ -2507,6 +2540,7 @@ function teardownVoiceTransport() { peerLastSeen = {}; deadPeers.clear(); if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } lastVoiceKeyRequestAt = 0; //Stale participants from the previous attempt must not linger in the UI voiceActiveUsers = []; @@ -2543,7 +2577,7 @@ function scheduleVoiceReconnect() { console.warn("Voice reconnect failed", err); scheduleVoiceReconnect(); } - }, 800); + }, Math.min(4000, 800 * voiceReconnectAttempts)); } function endVoiceSession() { @@ -2588,9 +2622,11 @@ function endVoiceSession() { if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } + if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } peerLastSeen = {}; deadPeers.clear(); lastVoiceKeyRequestAt = 0; + voiceReconnectAttempts = 0; voiceActiveUsers = []; voiceHadRemotePeers = false; @@ -2703,7 +2739,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { return { width, height, fps, bitrate }; }; - let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; + let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0; const encoder = new VideoEncoder({ output: async (chunk) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; @@ -2713,7 +2749,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { 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); currentVoiceWs.send(payload.buffer); - }, error: (e) => { stopFunction(); } + }, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); } }); encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); @@ -2733,7 +2769,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { pendingCfg = newConfig; pendingAt = performance.now(); } else if (performance.now() - pendingAt > 300) { - currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null; + currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null; encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); } } else if (pendingCfg) { @@ -2743,9 +2779,17 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { let frame = null; try { - frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); - frameCount++; - encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0}); + const nowMs = performance.now(); + frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); + //Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych + //klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał + //się z realnym tempem i keyframes stawały się rzadkie. Widz + //dołączający w trakcie udostępniania musi zaczekać na keyframe - + //z licznikiem potrafiło to trwać bardzo długo (efekt "kamery + //nie widać"), z zegarem to maksymalnie ~1 s. + const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); + encoder.encode(frame, {keyFrame: wantKeyframe}); + if (wantKeyframe) lastKeyframeAt = nowMs; } catch (err) { } finally { if (frame) frame.close(); } } setTimeout(encodeLoop, 1000 / currentConfig.fps);