diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css
index 95916d8c..a8d2d709 100644
--- a/android/app/src/main/assets/public/style.css
+++ b/android/app/src/main/assets/public/style.css
@@ -686,6 +686,7 @@ space{
flex-shrink: 0;
cursor: pointer;
margin: 0;
+ position: relative;
}
.room-pfp {
width: 2.2rem;
@@ -738,6 +739,7 @@ space{
display: flex;
flex-direction: column;
flex-grow: 1;
+ min-width: 0;
}
.chat-message-header {
display: flex;
@@ -1178,7 +1180,15 @@ input:checked + .toggle-slider:before {
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 {
+ position: absolute;
+ right: calc(0.6rem + (1rem - 0.65rem) / 2);
+ top: 50%;
+ margin-top: -0.325rem;
width: 0.65rem;
height: 0.65rem;
min-width: 0.65rem;
@@ -1186,7 +1196,6 @@ input:checked + .toggle-slider:before {
background-color: var(--big-red);
border-radius: 50%;
flex-shrink: 0;
- margin-right: 0.5rem;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(0);
}
diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js
index ea07e4c2..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,22 +125,26 @@ 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.
+//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 = 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;
-//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;
+let voiceRosterPollTimer = null;
let lastVoiceKeyRequestAt = 0;
let voiceReconnectAttempts = 0;
let voiceSocketOpenedAt = 0;
@@ -167,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(() => {});
}
@@ -217,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;
}
@@ -274,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);
@@ -630,7 +678,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 +745,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 +784,12 @@ 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 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;
for (let i = 0; i < output.length; i++) {
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
@@ -890,6 +947,38 @@ function bytesToB64u(a) {
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) {
if (!ticket) throw new Error("missing voice ticket");
// Single-session invariant: a stale socket (e.g. a double-fired join)
@@ -932,32 +1021,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 +1035,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ł
@@ -1033,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;
}
@@ -1053,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--;
@@ -1084,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];
@@ -1165,6 +1276,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 +1302,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 () => {
@@ -1332,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 });
@@ -2490,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) {
@@ -2507,6 +2619,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 = [];
@@ -2516,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();
}
@@ -2532,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 {
@@ -2543,7 +2666,7 @@ function scheduleVoiceReconnect() {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();
}
- }, 800);
+ }, Math.min(4000, 800 * voiceReconnectAttempts));
}
function endVoiceSession() {
@@ -2583,24 +2706,27 @@ 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; }
peerLastSeen = {};
deadPeers.clear();
lastVoiceKeyRequestAt = 0;
+ 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 = {};
@@ -2672,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);
@@ -2703,7 +2832,24 @@ 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;
+ //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;
@@ -2713,9 +2859,9 @@ 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'});
+ 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
@@ -2733,23 +2879,65 @@ 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;
- encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+ //Zmiana geometrii źródła - uczony scale łącza zostaje,
+ //resetujemy tylko redukcję FPS
+ 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) {
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 {
- frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
- frameCount++;
- encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
- } 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/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/screens.js b/webroot/screens.js
index 63c3484f..8366ba00 100644
--- a/webroot/screens.js
+++ b/webroot/screens.js
@@ -141,7 +141,7 @@ var invitesEntry = `
var chatScreen = `
-
+
diff --git a/webroot/style.css b/webroot/style.css
index 95916d8c..a8d2d709 100644
--- a/webroot/style.css
+++ b/webroot/style.css
@@ -686,6 +686,7 @@ space{
flex-shrink: 0;
cursor: pointer;
margin: 0;
+ position: relative;
}
.room-pfp {
width: 2.2rem;
@@ -738,6 +739,7 @@ space{
display: flex;
flex-direction: column;
flex-grow: 1;
+ min-width: 0;
}
.chat-message-header {
display: flex;
@@ -1178,7 +1180,15 @@ input:checked + .toggle-slider:before {
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 {
+ position: absolute;
+ right: calc(0.6rem + (1rem - 0.65rem) / 2);
+ top: 50%;
+ margin-top: -0.325rem;
width: 0.65rem;
height: 0.65rem;
min-width: 0.65rem;
@@ -1186,7 +1196,6 @@ input:checked + .toggle-slider:before {
background-color: var(--big-red);
border-radius: 50%;
flex-shrink: 0;
- margin-right: 0.5rem;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(0);
}
diff --git a/webroot/voice.js b/webroot/voice.js
index ea07e4c2..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,22 +125,26 @@ 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.
+//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 = 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;
-//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;
+let voiceRosterPollTimer = null;
let lastVoiceKeyRequestAt = 0;
let voiceReconnectAttempts = 0;
let voiceSocketOpenedAt = 0;
@@ -167,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(() => {});
}
@@ -217,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;
}
@@ -274,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);
@@ -630,7 +678,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 +745,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 +784,12 @@ 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 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;
for (let i = 0; i < output.length; i++) {
output[i] += (user.buffer[user.readPtr] / 32768) * vol;
@@ -890,6 +947,38 @@ function bytesToB64u(a) {
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) {
if (!ticket) throw new Error("missing voice ticket");
// Single-session invariant: a stale socket (e.g. a double-fired join)
@@ -932,32 +1021,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 +1035,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ł
@@ -1033,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;
}
@@ -1053,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--;
@@ -1084,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];
@@ -1165,6 +1276,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 +1302,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 () => {
@@ -1332,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 });
@@ -2490,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) {
@@ -2507,6 +2619,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 = [];
@@ -2516,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();
}
@@ -2532,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 {
@@ -2543,7 +2666,7 @@ function scheduleVoiceReconnect() {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();
}
- }, 800);
+ }, Math.min(4000, 800 * voiceReconnectAttempts));
}
function endVoiceSession() {
@@ -2583,24 +2706,27 @@ 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; }
peerLastSeen = {};
deadPeers.clear();
lastVoiceKeyRequestAt = 0;
+ 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 = {};
@@ -2672,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);
@@ -2703,7 +2832,24 @@ 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;
+ //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;
@@ -2713,9 +2859,9 @@ 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'});
+ 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
@@ -2733,23 +2879,65 @@ 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;
- encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+ //Zmiana geometrii źródła - uczony scale łącza zostaje,
+ //resetujemy tylko redukcję FPS
+ 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) {
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 {
- frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
- frameCount++;
- encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
- } 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(); };