From ffd952b1f0582050be155ec3fc6f6c56985b69d2 Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 26 Aug 2026 12:16:39 +0200 Subject: [PATCH] try to fix vc delay --- android/app/src/main/assets/public/voice.js | 280 +++++++++++++++----- webroot/voice.js | 280 +++++++++++++++----- 2 files changed, 424 insertions(+), 136 deletions(-) diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 9a17a654..15e7cea7 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -1,7 +1,6 @@ let currentVoiceWs = null; let currentVoiceCallId = null; let currentVoiceCallType = null; -let voiceWsHeartbeat = null; let voiceActiveUsers = []; let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceUiContainer = null; @@ -107,8 +106,8 @@ function getVoicePeerSenderKey(clientIdStr) { let micStream = null, audioContext = null, isAudioWorkletLoaded = false; -let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; -let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; +let micSourceNode = null, senderNode = null, receiverNode = null; +let remoteAudio = {}, audioDecryptChains = {}; let knownVoiceUsers = new Set(); let isMicMuted = false; let remoteVideos = {}, videoDecryptChains = {}; @@ -126,13 +125,14 @@ let isVoiceSpeakerMode = false; const SPEAKING_HANGOVER_MS = 400; -// ===== Voice connection resilience ===== -// Every client broadcasts a tiny encrypted ping so peers can tell "quiet -// user" apart from "crashed/half-open socket". Peers silent for too long are -// dropped locally; a dead server socket triggers an automatic rejoin. -// 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". +//Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione, +//więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy +//14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej. +const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024; +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_PING_INTERVAL_MS = 2000; const VOICE_SERVER_TIMEOUT_MS = 15000; @@ -141,8 +141,6 @@ const VOICE_ROSTER_POLL_MS = 5000; let peerLastSeen = {}; let deadPeers = new Set(); 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 voicePingTimer = null; let voiceRosterRetryTimer = null; @@ -172,11 +170,21 @@ function cleanupPeerMedia(userId) { function sendVoicePing() { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; const iv = nextVoiceIv(); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { + //Typ 5: prośba o echo. Payload = 8B timestamp (ms, WŁASNY zegar). Odbiorca + //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; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); + voiceOutstandingPings.set(tsData, Date.now()); + const nowMs = Date.now(); + for (const [k, sentAt] of voiceOutstandingPings) { + if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k); + } }).catch(() => {}); } @@ -222,9 +230,15 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60]; let voiceSettings = loadVoiceSettings(); function loadVoiceSettings() { - const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT }; + //Globalny default 480p@24; stare zapisane defaulty (720p/1080p/30fps) + //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 { - return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {}); + let s = Object.assign({}, 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) { return defaults; } @@ -279,6 +293,35 @@ async function applyVoiceSpeakerSink() { let voiceConnectedAt = null; let voiceTimerInterval = 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) { let totalSec = Math.floor(ms / 1000); @@ -742,13 +785,8 @@ class AudioReceiver extends AudioWorkletProcessor { else { if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 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. + //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; } @@ -909,11 +947,9 @@ 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. +//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 { @@ -922,8 +958,11 @@ async function fetchVoiceRoster() { 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(); + //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) @@ -1062,12 +1101,18 @@ async function startVoiceSession(serverUrl, ticket) { } let vadHangover = 0; + const myIdStr = String(window.id); senderNode.port.onmessage = async (e) => { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - // Backpressure valve: when the socket is congested, drop mic - // frames instead of queueing them - queued audio would arrive - // minutes late and drown the connection for every stream. - if (currentVoiceWs.bufferedAmount > 256 * 1024) { + //Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla + //bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej + //urwać ułamek sekundy niż mówić z sekundami opóźnienia. + if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) { + vadHangover = 0; + return; + } + if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700; + if (Date.now() < voiceAudioHoldUntil) { vadHangover = 0; return; } @@ -1082,7 +1127,7 @@ async function startVoiceSession(serverUrl, ticket) { if (db >= sensitivity) vadHangover = 75; let isSpeaking = vadHangover > 0 && !isMicMuted; - setTileSpeaking(String(window.id), isSpeaking); + setTileSpeaking(myIdStr, isSpeaking); if (vadHangover > 0 && !isMicMuted) { vadHangover--; @@ -1113,7 +1158,44 @@ async function startVoiceSession(serverUrl, ticket) { if (deadPeers.has(clientId)) deadPeers.delete(clientId); const type = view.getUint8(8); - if (type === 5) return; // liveness ping - presence already recorded + if (type === 5 || type === 6) { + //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)) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; @@ -1365,8 +1447,6 @@ async function handleCallUpdate(data) { for (let u in remoteAudio) { let pureU = u.replace("_screen", ""); if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) { - if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){} - delete audioDecoders[u]; delete remoteAudio[u]; const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode; if(rn) rn.port.postMessage({ action: 'remove', clientId: u }); @@ -2523,7 +2603,6 @@ function toggleVoiceMic() { // keeping the UI, mic stream and AudioContext alive for an automatic rejoin. function teardownVoiceTransport() { if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } if (currentVoiceWs) { @@ -2550,6 +2629,12 @@ function teardownVoiceTransport() { setVoiceRootKey(null); voiceSendEpoch = null; voiceSendCounter = 0n; + voiceRttFastMs = 0; + voiceRttSamples = []; + voiceRttBaseMs = 0; + voiceAudioHoldUntil = 0; + voiceOutstandingPings.clear(); + voiceLastRttSampleAt = 0; lastVoiceServerPacketAt = Date.now(); } @@ -2566,6 +2651,10 @@ function scheduleVoiceReconnect() { voiceReconnectAttempts++; setVoiceStatus("connecting"); 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 () => { if (!currentVoiceCallId || !currentVoiceCallType) return; try { @@ -2617,9 +2706,7 @@ function endVoiceSession() { micStream = 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 (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } @@ -2629,14 +2716,17 @@ function endVoiceSession() { voiceReconnectAttempts = 0; voiceActiveUsers = []; voiceHadRemotePeers = false; - - for (let id in audioDecoders) { - try { audioDecoders[id].close(); } catch(e){} - } + voiceRttFastMs = 0; + voiceRttSamples = []; + voiceRttBaseMs = 0; + voiceAudioHoldUntil = 0; + voiceOutstandingPings.clear(); + voiceLastEchoAt = 0; + voiceLastRttSampleAt = 0; + for (let id in audioDecryptChains) { if(audioDecryptChains[id]) audioDecryptChains[id].clear(); } - audioDecoders = {}; audioDecryptChains = {}; remoteAudio = {}; @@ -2708,8 +2798,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; - // Same backpressure valve as the mic sender. - if (currentVoiceWs.bufferedAmount > 256 * 1024) return; + // Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio + // ma pierwszeństwo przed wideo na wspólnym sockecie. + 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 cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -2740,6 +2833,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { }; let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 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({ output: async (chunk) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; @@ -2751,7 +2861,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { currentVoiceWs.send(payload.buffer); }, 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'}); + encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'}); // Transient capture-mode wobbles must not thrash the encoder - // commit only after ~300 ms stable. Camera size/fps changes arrive @@ -2769,31 +2879,65 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { pendingCfg = newConfig; pendingAt = performance.now(); } else if (performance.now() - pendingAt > 300) { + //Zmiana geometrii źródła - uczony scale łącza zostaje, + //resetujemy tylko redukcję FPS 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'}); + activeBitrate = Math.round(currentConfig.bitrate * brScale); + fpsDiv = 1; lowStreak = 0; cleanStreak = 0; + applyEncoderBitrate(true); } } else if (pendingCfg) { pendingCfg = null; // wiggle reverted on its own - ignore } - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { - let frame = null; - try { - 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); - }; + //Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio + //(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; + try { + const nowMs = performance.now(); + frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); + //Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija) + const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); + encoder.encode(frame, {keyFrame: wantKeyframe}); + if (wantKeyframe) lastKeyframeAt = nowMs; + } 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, 500); videoTrack.onended = () => { if(isRunning) stopFunction(); }; diff --git a/webroot/voice.js b/webroot/voice.js index 9a17a654..15e7cea7 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -1,7 +1,6 @@ let currentVoiceWs = null; let currentVoiceCallId = null; let currentVoiceCallType = null; -let voiceWsHeartbeat = null; let voiceActiveUsers = []; let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceUiContainer = null; @@ -107,8 +106,8 @@ function getVoicePeerSenderKey(clientIdStr) { let micStream = null, audioContext = null, isAudioWorkletLoaded = false; -let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; -let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; +let micSourceNode = null, senderNode = null, receiverNode = null; +let remoteAudio = {}, audioDecryptChains = {}; let knownVoiceUsers = new Set(); let isMicMuted = false; let remoteVideos = {}, videoDecryptChains = {}; @@ -126,13 +125,14 @@ let isVoiceSpeakerMode = false; const SPEAKING_HANGOVER_MS = 400; -// ===== Voice connection resilience ===== -// Every client broadcasts a tiny encrypted ping so peers can tell "quiet -// user" apart from "crashed/half-open socket". Peers silent for too long are -// dropped locally; a dead server socket triggers an automatic rejoin. -// 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". +//Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione, +//więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy +//14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej. +const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024; +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_PING_INTERVAL_MS = 2000; const VOICE_SERVER_TIMEOUT_MS = 15000; @@ -141,8 +141,6 @@ const VOICE_ROSTER_POLL_MS = 5000; let peerLastSeen = {}; let deadPeers = new Set(); 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 voicePingTimer = null; let voiceRosterRetryTimer = null; @@ -172,11 +170,21 @@ function cleanupPeerMedia(userId) { function sendVoicePing() { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; const iv = nextVoiceIv(); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { + //Typ 5: prośba o echo. Payload = 8B timestamp (ms, WŁASNY zegar). Odbiorca + //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; const payload = new Uint8Array(14 + cipher.byteLength); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); + voiceOutstandingPings.set(tsData, Date.now()); + const nowMs = Date.now(); + for (const [k, sentAt] of voiceOutstandingPings) { + if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k); + } }).catch(() => {}); } @@ -222,9 +230,15 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60]; let voiceSettings = loadVoiceSettings(); function loadVoiceSettings() { - const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT }; + //Globalny default 480p@24; stare zapisane defaulty (720p/1080p/30fps) + //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 { - return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {}); + let s = Object.assign({}, 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) { return defaults; } @@ -279,6 +293,35 @@ async function applyVoiceSpeakerSink() { let voiceConnectedAt = null; let voiceTimerInterval = 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) { let totalSec = Math.floor(ms / 1000); @@ -742,13 +785,8 @@ class AudioReceiver extends AudioWorkletProcessor { else { if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 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. + //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; } @@ -909,11 +947,9 @@ 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. +//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 { @@ -922,8 +958,11 @@ async function fetchVoiceRoster() { 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(); + //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) @@ -1062,12 +1101,18 @@ async function startVoiceSession(serverUrl, ticket) { } let vadHangover = 0; + const myIdStr = String(window.id); senderNode.port.onmessage = async (e) => { if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - // Backpressure valve: when the socket is congested, drop mic - // frames instead of queueing them - queued audio would arrive - // minutes late and drown the connection for every stream. - if (currentVoiceWs.bufferedAmount > 256 * 1024) { + //Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla + //bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej + //urwać ułamek sekundy niż mówić z sekundami opóźnienia. + if (currentVoiceWs.bufferedAmount > VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES) { + vadHangover = 0; + return; + } + if (voiceCongestionDelay() > 450) voiceAudioHoldUntil = Date.now() + 700; + if (Date.now() < voiceAudioHoldUntil) { vadHangover = 0; return; } @@ -1082,7 +1127,7 @@ async function startVoiceSession(serverUrl, ticket) { if (db >= sensitivity) vadHangover = 75; let isSpeaking = vadHangover > 0 && !isMicMuted; - setTileSpeaking(String(window.id), isSpeaking); + setTileSpeaking(myIdStr, isSpeaking); if (vadHangover > 0 && !isMicMuted) { vadHangover--; @@ -1113,7 +1158,44 @@ async function startVoiceSession(serverUrl, ticket) { if (deadPeers.has(clientId)) deadPeers.delete(clientId); const type = view.getUint8(8); - if (type === 5) return; // liveness ping - presence already recorded + if (type === 5 || type === 6) { + //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)) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; @@ -1365,8 +1447,6 @@ async function handleCallUpdate(data) { for (let u in remoteAudio) { let pureU = u.replace("_screen", ""); if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) { - if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){} - delete audioDecoders[u]; delete remoteAudio[u]; const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode; if(rn) rn.port.postMessage({ action: 'remove', clientId: u }); @@ -2523,7 +2603,6 @@ function toggleVoiceMic() { // keeping the UI, mic stream and AudioContext alive for an automatic rejoin. function teardownVoiceTransport() { if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } if (currentVoiceWs) { @@ -2550,6 +2629,12 @@ function teardownVoiceTransport() { setVoiceRootKey(null); voiceSendEpoch = null; voiceSendCounter = 0n; + voiceRttFastMs = 0; + voiceRttSamples = []; + voiceRttBaseMs = 0; + voiceAudioHoldUntil = 0; + voiceOutstandingPings.clear(); + voiceLastRttSampleAt = 0; lastVoiceServerPacketAt = Date.now(); } @@ -2566,6 +2651,10 @@ function scheduleVoiceReconnect() { voiceReconnectAttempts++; setVoiceStatus("connecting"); 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 () => { if (!currentVoiceCallId || !currentVoiceCallType) return; try { @@ -2617,9 +2706,7 @@ function endVoiceSession() { micStream = 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 (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } @@ -2629,14 +2716,17 @@ function endVoiceSession() { voiceReconnectAttempts = 0; voiceActiveUsers = []; voiceHadRemotePeers = false; - - for (let id in audioDecoders) { - try { audioDecoders[id].close(); } catch(e){} - } + voiceRttFastMs = 0; + voiceRttSamples = []; + voiceRttBaseMs = 0; + voiceAudioHoldUntil = 0; + voiceOutstandingPings.clear(); + voiceLastEchoAt = 0; + voiceLastRttSampleAt = 0; + for (let id in audioDecryptChains) { if(audioDecryptChains[id]) audioDecryptChains[id].clear(); } - audioDecoders = {}; audioDecryptChains = {}; remoteAudio = {}; @@ -2708,8 +2798,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; - // Same backpressure valve as the mic sender. - if (currentVoiceWs.bufferedAmount > 256 * 1024) return; + // Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio + // ma pierwszeństwo przed wideo na wspólnym sockecie. + 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 cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -2740,6 +2833,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { }; let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 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({ output: async (chunk) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; @@ -2751,7 +2861,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { currentVoiceWs.send(payload.buffer); }, 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'}); + encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'}); // Transient capture-mode wobbles must not thrash the encoder - // commit only after ~300 ms stable. Camera size/fps changes arrive @@ -2769,31 +2879,65 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { pendingCfg = newConfig; pendingAt = performance.now(); } else if (performance.now() - pendingAt > 300) { + //Zmiana geometrii źródła - uczony scale łącza zostaje, + //resetujemy tylko redukcję FPS 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'}); + activeBitrate = Math.round(currentConfig.bitrate * brScale); + fpsDiv = 1; lowStreak = 0; cleanStreak = 0; + applyEncoderBitrate(true); } } else if (pendingCfg) { pendingCfg = null; // wiggle reverted on its own - ignore } - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { - let frame = null; - try { - 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); - }; + //Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio + //(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; + try { + const nowMs = performance.now(); + frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); + //Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija) + const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); + encoder.encode(frame, {keyFrame: wantKeyframe}); + if (wantKeyframe) lastKeyframeAt = nowMs; + } 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, 500); videoTrack.onended = () => { if(isRunning) stopFunction(); };