try to fix vc delay
All checks were successful
Android Build / publish (push) Successful in 48s
Linux Build / publish (push) Successful in 59s

This commit is contained in:
olcxja 2026-08-26 12:16:39 +02:00
commit ffd952b1f0
2 changed files with 424 additions and 136 deletions

View file

@ -1,7 +1,6 @@
let currentVoiceWs = null; let currentVoiceWs = null;
let currentVoiceCallId = null; let currentVoiceCallId = null;
let currentVoiceCallType = null; let currentVoiceCallType = null;
let voiceWsHeartbeat = null;
let voiceActiveUsers = []; let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
let voiceUiContainer = null; let voiceUiContainer = null;
@ -107,8 +106,8 @@ function getVoicePeerSenderKey(clientIdStr) {
let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let micSourceNode = null, senderNode = null, receiverNode = null;
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; let remoteAudio = {}, audioDecryptChains = {};
let knownVoiceUsers = new Set(); let knownVoiceUsers = new Set();
let isMicMuted = false; let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {}; let remoteVideos = {}, videoDecryptChains = {};
@ -126,13 +125,14 @@ let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400; const SPEAKING_HANGOVER_MS = 400;
// ===== Voice connection resilience ===== //Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione,
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet //więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy
// user" apart from "crashed/half-open socket". Peers silent for too long are //14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej.
// dropped locally; a dead server socket triggers an automatic rejoin. const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024;
// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone): const VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES = 14 * 1024;
// 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". //Resilience: peer bez pakietów >7 s = lokalnie martwy (media wycięte), socket
//bez ruchu od ŻYWYCH peerów >15 s = wymuszony reconnect. Pings co 2 s.
const VOICE_PEER_TIMEOUT_MS = 7000; const VOICE_PEER_TIMEOUT_MS = 7000;
const VOICE_PING_INTERVAL_MS = 2000; const VOICE_PING_INTERVAL_MS = 2000;
const VOICE_SERVER_TIMEOUT_MS = 15000; const VOICE_SERVER_TIMEOUT_MS = 15000;
@ -141,8 +141,6 @@ const VOICE_ROSTER_POLL_MS = 5000;
let peerLastSeen = {}; let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
let voiceHadRemotePeers = false; let voiceHadRemotePeers = false;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null; let voiceRosterRetryTimer = null;
@ -172,11 +170,21 @@ function cleanupPeerMedia(userId) {
function sendVoicePing() { function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const iv = nextVoiceIv(); const iv = nextVoiceIv();
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; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
voiceOutstandingPings.set(tsData, Date.now());
const nowMs = Date.now();
for (const [k, sentAt] of voiceOutstandingPings) {
if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k);
}
}).catch(() => {}); }).catch(() => {});
} }
@ -222,9 +230,15 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
let voiceSettings = loadVoiceSettings(); let voiceSettings = loadVoiceSettings();
function 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 { 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) { } catch (e) {
return defaults; return defaults;
} }
@ -279,6 +293,35 @@ async function applyVoiceSpeakerSink() {
let voiceConnectedAt = null; let voiceConnectedAt = null;
let voiceTimerInterval = null; let voiceTimerInterval = null;
let voicePingMs = null; let voicePingMs = null;
//Kontrola congestionu: bufferedAmount nie widzi backlogu kernela TCP, prawdziwy
//obraz daje RTT z pingów. Baseline = MINIMUM z okna próbek (odporne na skoki
//i na peera sztucznie opóźniającego własne echa - stałe kłamstwo podnosi minimum).
let voiceRttFastMs = 0;
let voiceRttSamples = [];
let voiceRttBaseMs = 0;
let voiceAudioHoldUntil = 0; //histereza zaworu audio
//Echo-ping (typ 5 = prośba, typ 6 = echo): RTT wyłącznie na WŁASNYM zegarze -
//wysyłam timestamp, peer odbija te same bajty, odejmuję czas nadania. Zegar
//peera nigdy nie jest używany (może być przekręcony lub spreparowany).
const voiceOutstandingPings = new Map(); // BigInt(ts) -> czas nadania
let voiceLastEchoAt = 0; // rate-limit odpowiedzi echem
let voiceLastRttSampleAt = 0; // kiedy ostatnio spłynął prawdziwy pomiar
function voiceCongestionDelay() {
if (!voiceRttFastMs || !voiceRttBaseMs) return 0;
return Math.min(5000, Math.max(0, voiceRttFastMs - voiceRttBaseMs));
}
function voiceRecordRttSample(rtt) {
voiceLastRttSampleAt = Date.now();
voicePingMs = voicePingMs === null ? rtt : Math.round(voicePingMs * 0.7 + rtt * 0.3);
voiceRttFastMs = voiceRttFastMs ? Math.round(voiceRttFastMs * 0.5 + rtt * 0.5) : rtt;
voiceRttSamples.push(rtt);
if (voiceRttSamples.length > 12) voiceRttSamples.shift();
voiceRttBaseMs = Math.min.apply(null, voiceRttSamples);
updateVoicePing();
}
function formatVoiceDuration(ms) { function formatVoiceDuration(ms) {
let totalSec = Math.floor(ms / 1000); let totalSec = Math.floor(ms / 1000);
@ -742,13 +785,8 @@ class AudioReceiver extends AudioWorkletProcessor {
else { else {
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536; if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
else if (available > 2049) { else if (available > 2049) {
//Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany //Adaptacyjny drain: nadmiar nad target odprowadzany proporcjonalnie
//proporcjonalnie (1 + excess/256 próbek na kwant), max ~17 //(1 + excess/256 /kwant) - powrót z ~128 ms do ~43 ms w ułamku sekundy
//= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale
//"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało,
//by po każdym burstu sieciowym latency wisiał pod twardym
//limitem przez kilkanaście sekund - odczuwalne jako "dziwnie
//duże opóźnienie" na sieci komórkowej.
const excess = available - 2048; const excess = available - 2048;
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536; user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536;
} }
@ -909,11 +947,9 @@ function bytesToB64u(a) {
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje //Roster pokoju z voice-servera (źródło prawdy) -> synchronizacja kafelków.
//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia //Steady-poll co VOICE_ROSTER_POLL_MS - bez tego sweep ghosta/leave bez
//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co //call_update nigdy nie docierał do UI.
//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po
//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków.
async function fetchVoiceRoster() { async function fetchVoiceRoster() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
try { try {
@ -922,8 +958,11 @@ async function fetchVoiceRoster() {
const t0 = performance.now(); const t0 = performance.now();
const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`); const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`);
const usersRaw = await res.json(); const usersRaw = await res.json();
voicePingMs = Math.round(performance.now() - t0); //HTTP tylko jako szacunek gdy brak prawdziwych próbek RTT (solo / stary peer)
updateVoicePing(); if (voicePingMs === null || Date.now() - voiceLastRttSampleAt > 25000) {
voicePingMs = Math.round(performance.now() - t0);
updateVoicePing();
}
const rawUsers = usersRaw.map(u => String(u)); const rawUsers = usersRaw.map(u => String(u));
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
//deadPeers (audyt S15: ukryty peer nadal dostaje audio) //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
@ -1062,12 +1101,18 @@ async function startVoiceSession(serverUrl, ticket) {
} }
let vadHangover = 0; let vadHangover = 0;
const myIdStr = String(window.id);
senderNode.port.onmessage = async (e) => { senderNode.port.onmessage = async (e) => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
// Backpressure valve: when the socket is congested, drop mic //Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla
// frames instead of queueing them - queued audio would arrive //bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej
// minutes late and drown the connection for every stream. //urwać ułamek sekundy niż mówić z sekundami opóźnienia.
if (currentVoiceWs.bufferedAmount > 256 * 1024) { 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; vadHangover = 0;
return; return;
} }
@ -1082,7 +1127,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (db >= sensitivity) vadHangover = 75; if (db >= sensitivity) vadHangover = 75;
let isSpeaking = vadHangover > 0 && !isMicMuted; let isSpeaking = vadHangover > 0 && !isMicMuted;
setTileSpeaking(String(window.id), isSpeaking); setTileSpeaking(myIdStr, isSpeaking);
if (vadHangover > 0 && !isMicMuted) { if (vadHangover > 0 && !isMicMuted) {
vadHangover--; vadHangover--;
@ -1113,7 +1158,44 @@ async function startVoiceSession(serverUrl, ticket) {
if (deadPeers.has(clientId)) deadPeers.delete(clientId); if (deadPeers.has(clientId)) deadPeers.delete(clientId);
const type = view.getUint8(8); const type = view.getUint8(8);
if (type === 5) 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)) { if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
const baseUrl = currentVoiceWs.url.split('/ws')[0]; const baseUrl = currentVoiceWs.url.split('/ws')[0];
@ -1365,8 +1447,6 @@ async function handleCallUpdate(data) {
for (let u in remoteAudio) { for (let u in remoteAudio) {
let pureU = u.replace("_screen", ""); let pureU = u.replace("_screen", "");
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) { if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
delete audioDecoders[u];
delete remoteAudio[u]; delete remoteAudio[u];
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode; const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.port.postMessage({ action: 'remove', clientId: u }); if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
@ -2523,7 +2603,6 @@ function toggleVoiceMic() {
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin. // keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
function teardownVoiceTransport() { function teardownVoiceTransport() {
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
if (currentVoiceWs) { if (currentVoiceWs) {
@ -2550,6 +2629,12 @@ function teardownVoiceTransport() {
setVoiceRootKey(null); setVoiceRootKey(null);
voiceSendEpoch = null; voiceSendEpoch = null;
voiceSendCounter = 0n; voiceSendCounter = 0n;
voiceRttFastMs = 0;
voiceRttSamples = [];
voiceRttBaseMs = 0;
voiceAudioHoldUntil = 0;
voiceOutstandingPings.clear();
voiceLastRttSampleAt = 0;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
} }
@ -2566,6 +2651,10 @@ function scheduleVoiceReconnect() {
voiceReconnectAttempts++; voiceReconnectAttempts++;
setVoiceStatus("connecting"); setVoiceStatus("connecting");
teardownVoiceTransport(); teardownVoiceTransport();
//Zmiana sieci = stary RTT dotyczy martwej ścieżki; bez resetu wskaźnik
//wisiałby zamrożony, a fallback HTTP nie miał prawa go nadpisać
voicePingMs = null;
updateVoicePing();
setTimeout(async () => { setTimeout(async () => {
if (!currentVoiceCallId || !currentVoiceCallType) return; if (!currentVoiceCallId || !currentVoiceCallType) return;
try { try {
@ -2617,9 +2706,7 @@ function endVoiceSession() {
micStream = null; micStream = null;
} }
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
@ -2629,14 +2716,17 @@ function endVoiceSession() {
voiceReconnectAttempts = 0; voiceReconnectAttempts = 0;
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false; voiceHadRemotePeers = false;
voiceRttFastMs = 0;
voiceRttSamples = [];
voiceRttBaseMs = 0;
voiceAudioHoldUntil = 0;
voiceOutstandingPings.clear();
voiceLastEchoAt = 0;
voiceLastRttSampleAt = 0;
for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){}
}
for (let id in audioDecryptChains) { for (let id in audioDecryptChains) {
if(audioDecryptChains[id]) audioDecryptChains[id].clear(); if(audioDecryptChains[id]) audioDecryptChains[id].clear();
} }
audioDecoders = {};
audioDecryptChains = {}; audioDecryptChains = {};
remoteAudio = {}; remoteAudio = {};
@ -2708,8 +2798,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
screenSenderNode.port.onmessage = async (e) => { screenSenderNode.port.onmessage = async (e) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
// Same backpressure valve as the mic sender. // Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio
if (currentVoiceWs.bufferedAmount > 256 * 1024) return; // 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 iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -2740,6 +2833,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
}; };
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 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({ const encoder = new VideoEncoder({
output: async (chunk) => { output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
@ -2751,7 +2861,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
}, error: (e) => { voiceLogError('videoencoder', 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 - // Transient capture-mode wobbles must not thrash the encoder -
// commit only after ~300 ms stable. Camera size/fps changes arrive // commit only after ~300 ms stable. Camera size/fps changes arrive
@ -2769,31 +2879,65 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
pendingCfg = newConfig; pendingCfg = newConfig;
pendingAt = performance.now(); pendingAt = performance.now();
} else if (performance.now() - pendingAt > 300) { } else if (performance.now() - pendingAt > 300) {
//Zmiana geometrii źródła - uczony scale łącza zostaje,
//resetujemy tylko redukcję FPS
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null; currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); activeBitrate = Math.round(currentConfig.bitrate * brScale);
fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
applyEncoderBitrate(true);
} }
} else if (pendingCfg) { } else if (pendingCfg) {
pendingCfg = null; // wiggle reverted on its own - ignore pendingCfg = null; // wiggle reverted on its own - ignore
} }
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { //Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio
let frame = null; //(14 KB) - przy korku najpierw znika obraz, głos płynie dalej.
try { tickCount++;
const nowMs = performance.now(); const policySkip = fpsDiv > 1 && (tickCount % fpsDiv !== 0);
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); let skipped = false;
//Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych if (policySkip) {
//klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał //celowe tempo (fpsDiv) - nie liczy się do skipRatio
//się z realnym tempem i keyframes stawały się rzadkie. Widz } else if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 &&
//dołączający w trakcie udostępniania musi zaczekać na keyframe - currentVoiceWs.readyState === WebSocket.OPEN &&
//z licznikiem potrafiło to trwać bardzo długo (efekt "kamery currentVoiceWs.bufferedAmount < VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES && videoEl.readyState >= 2) {
//nie widać"), z zegarem to maksymalnie ~1 s. let frame = null;
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); try {
encoder.encode(frame, {keyFrame: wantKeyframe}); const nowMs = performance.now();
if (wantKeyframe) lastKeyframeAt = nowMs; frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
} catch (err) { } finally { if (frame) frame.close(); } //Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija)
} const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
setTimeout(encodeLoop, 1000 / currentConfig.fps); 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); setTimeout(encodeLoop, 500);
videoTrack.onended = () => { if(isRunning) stopFunction(); }; videoTrack.onended = () => { if(isRunning) stopFunction(); };

View file

@ -1,7 +1,6 @@
let currentVoiceWs = null; let currentVoiceWs = null;
let currentVoiceCallId = null; let currentVoiceCallId = null;
let currentVoiceCallType = null; let currentVoiceCallType = null;
let voiceWsHeartbeat = null;
let voiceActiveUsers = []; let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
let voiceUiContainer = null; let voiceUiContainer = null;
@ -107,8 +106,8 @@ function getVoicePeerSenderKey(clientIdStr) {
let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let micSourceNode = null, senderNode = null, receiverNode = null;
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; let remoteAudio = {}, audioDecryptChains = {};
let knownVoiceUsers = new Set(); let knownVoiceUsers = new Set();
let isMicMuted = false; let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {}; let remoteVideos = {}, videoDecryptChains = {};
@ -126,13 +125,14 @@ let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400; const SPEAKING_HANGOVER_MS = 400;
// ===== Voice connection resilience ===== //Zawory kolejki socketu: TCP dowiezie zakolejkowane bajty tylko spóźnione,
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet //więc progi są hierarchiczne - wideo ustępuje przy 12 KB, audio dopiero przy
// user" apart from "crashed/half-open socket". Peers silent for too long are //14 KB (~270 ms głosu). Przy korku najpierw znika obraz, głos leci dalej.
// dropped locally; a dead server socket triggers an automatic rejoin. const VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES = 12 * 1024;
// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone): const VOICE_WS_AUDIO_QUEUE_LIMIT_BYTES = 14 * 1024;
// 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". //Resilience: peer bez pakietów >7 s = lokalnie martwy (media wycięte), socket
//bez ruchu od ŻYWYCH peerów >15 s = wymuszony reconnect. Pings co 2 s.
const VOICE_PEER_TIMEOUT_MS = 7000; const VOICE_PEER_TIMEOUT_MS = 7000;
const VOICE_PING_INTERVAL_MS = 2000; const VOICE_PING_INTERVAL_MS = 2000;
const VOICE_SERVER_TIMEOUT_MS = 15000; const VOICE_SERVER_TIMEOUT_MS = 15000;
@ -141,8 +141,6 @@ const VOICE_ROSTER_POLL_MS = 5000;
let peerLastSeen = {}; let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
let voiceHadRemotePeers = false; let voiceHadRemotePeers = false;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null; let voiceRosterRetryTimer = null;
@ -172,11 +170,21 @@ function cleanupPeerMedia(userId) {
function sendVoicePing() { function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const iv = nextVoiceIv(); const iv = nextVoiceIv();
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; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
voiceOutstandingPings.set(tsData, Date.now());
const nowMs = Date.now();
for (const [k, sentAt] of voiceOutstandingPings) {
if (nowMs - sentAt > 15000) voiceOutstandingPings.delete(k);
}
}).catch(() => {}); }).catch(() => {});
} }
@ -222,9 +230,15 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
let voiceSettings = loadVoiceSettings(); let voiceSettings = loadVoiceSettings();
function 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 { 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) { } catch (e) {
return defaults; return defaults;
} }
@ -279,6 +293,35 @@ async function applyVoiceSpeakerSink() {
let voiceConnectedAt = null; let voiceConnectedAt = null;
let voiceTimerInterval = null; let voiceTimerInterval = null;
let voicePingMs = null; let voicePingMs = null;
//Kontrola congestionu: bufferedAmount nie widzi backlogu kernela TCP, prawdziwy
//obraz daje RTT z pingów. Baseline = MINIMUM z okna próbek (odporne na skoki
//i na peera sztucznie opóźniającego własne echa - stałe kłamstwo podnosi minimum).
let voiceRttFastMs = 0;
let voiceRttSamples = [];
let voiceRttBaseMs = 0;
let voiceAudioHoldUntil = 0; //histereza zaworu audio
//Echo-ping (typ 5 = prośba, typ 6 = echo): RTT wyłącznie na WŁASNYM zegarze -
//wysyłam timestamp, peer odbija te same bajty, odejmuję czas nadania. Zegar
//peera nigdy nie jest używany (może być przekręcony lub spreparowany).
const voiceOutstandingPings = new Map(); // BigInt(ts) -> czas nadania
let voiceLastEchoAt = 0; // rate-limit odpowiedzi echem
let voiceLastRttSampleAt = 0; // kiedy ostatnio spłynął prawdziwy pomiar
function voiceCongestionDelay() {
if (!voiceRttFastMs || !voiceRttBaseMs) return 0;
return Math.min(5000, Math.max(0, voiceRttFastMs - voiceRttBaseMs));
}
function voiceRecordRttSample(rtt) {
voiceLastRttSampleAt = Date.now();
voicePingMs = voicePingMs === null ? rtt : Math.round(voicePingMs * 0.7 + rtt * 0.3);
voiceRttFastMs = voiceRttFastMs ? Math.round(voiceRttFastMs * 0.5 + rtt * 0.5) : rtt;
voiceRttSamples.push(rtt);
if (voiceRttSamples.length > 12) voiceRttSamples.shift();
voiceRttBaseMs = Math.min.apply(null, voiceRttSamples);
updateVoicePing();
}
function formatVoiceDuration(ms) { function formatVoiceDuration(ms) {
let totalSec = Math.floor(ms / 1000); let totalSec = Math.floor(ms / 1000);
@ -742,13 +785,8 @@ class AudioReceiver extends AudioWorkletProcessor {
else { else {
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536; if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
else if (available > 2049) { else if (available > 2049) {
//Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany //Adaptacyjny drain: nadmiar nad target odprowadzany proporcjonalnie
//proporcjonalnie (1 + excess/256 próbek na kwant), max ~17 //(1 + excess/256 /kwant) - powrót z ~128 ms do ~43 ms w ułamku sekundy
//= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale
//"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało,
//by po każdym burstu sieciowym latency wisiał pod twardym
//limitem przez kilkanaście sekund - odczuwalne jako "dziwnie
//duże opóźnienie" na sieci komórkowej.
const excess = available - 2048; const excess = available - 2048;
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536; user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 65536;
} }
@ -909,11 +947,9 @@ function bytesToB64u(a) {
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje //Roster pokoju z voice-servera (źródło prawdy) -> synchronizacja kafelków.
//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia //Steady-poll co VOICE_ROSTER_POLL_MS - bez tego sweep ghosta/leave bez
//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co //call_update nigdy nie docierał do UI.
//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po
//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków.
async function fetchVoiceRoster() { async function fetchVoiceRoster() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
try { try {
@ -922,8 +958,11 @@ async function fetchVoiceRoster() {
const t0 = performance.now(); const t0 = performance.now();
const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`); const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`);
const usersRaw = await res.json(); const usersRaw = await res.json();
voicePingMs = Math.round(performance.now() - t0); //HTTP tylko jako szacunek gdy brak prawdziwych próbek RTT (solo / stary peer)
updateVoicePing(); if (voicePingMs === null || Date.now() - voiceLastRttSampleAt > 25000) {
voicePingMs = Math.round(performance.now() - t0);
updateVoicePing();
}
const rawUsers = usersRaw.map(u => String(u)); const rawUsers = usersRaw.map(u => String(u));
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
//deadPeers (audyt S15: ukryty peer nadal dostaje audio) //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
@ -1062,12 +1101,18 @@ async function startVoiceSession(serverUrl, ticket) {
} }
let vadHangover = 0; let vadHangover = 0;
const myIdStr = String(window.id);
senderNode.port.onmessage = async (e) => { senderNode.port.onmessage = async (e) => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
// Backpressure valve: when the socket is congested, drop mic //Zawór bajtowy + zawór RTT (kolejka kernela jest niewidoczna dla
// frames instead of queueing them - queued audio would arrive //bufferedAmount). Drop >450 ms nadwyżki z histerezą 700 ms - lepiej
// minutes late and drown the connection for every stream. //urwać ułamek sekundy niż mówić z sekundami opóźnienia.
if (currentVoiceWs.bufferedAmount > 256 * 1024) { 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; vadHangover = 0;
return; return;
} }
@ -1082,7 +1127,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (db >= sensitivity) vadHangover = 75; if (db >= sensitivity) vadHangover = 75;
let isSpeaking = vadHangover > 0 && !isMicMuted; let isSpeaking = vadHangover > 0 && !isMicMuted;
setTileSpeaking(String(window.id), isSpeaking); setTileSpeaking(myIdStr, isSpeaking);
if (vadHangover > 0 && !isMicMuted) { if (vadHangover > 0 && !isMicMuted) {
vadHangover--; vadHangover--;
@ -1113,7 +1158,44 @@ async function startVoiceSession(serverUrl, ticket) {
if (deadPeers.has(clientId)) deadPeers.delete(clientId); if (deadPeers.has(clientId)) deadPeers.delete(clientId);
const type = view.getUint8(8); const type = view.getUint8(8);
if (type === 5) 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)) { if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
const baseUrl = currentVoiceWs.url.split('/ws')[0]; const baseUrl = currentVoiceWs.url.split('/ws')[0];
@ -1365,8 +1447,6 @@ async function handleCallUpdate(data) {
for (let u in remoteAudio) { for (let u in remoteAudio) {
let pureU = u.replace("_screen", ""); let pureU = u.replace("_screen", "");
if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) { if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
delete audioDecoders[u];
delete remoteAudio[u]; delete remoteAudio[u];
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode; const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.port.postMessage({ action: 'remove', clientId: u }); if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
@ -2523,7 +2603,6 @@ function toggleVoiceMic() {
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin. // keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
function teardownVoiceTransport() { function teardownVoiceTransport() {
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
if (currentVoiceWs) { if (currentVoiceWs) {
@ -2550,6 +2629,12 @@ function teardownVoiceTransport() {
setVoiceRootKey(null); setVoiceRootKey(null);
voiceSendEpoch = null; voiceSendEpoch = null;
voiceSendCounter = 0n; voiceSendCounter = 0n;
voiceRttFastMs = 0;
voiceRttSamples = [];
voiceRttBaseMs = 0;
voiceAudioHoldUntil = 0;
voiceOutstandingPings.clear();
voiceLastRttSampleAt = 0;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
} }
@ -2566,6 +2651,10 @@ function scheduleVoiceReconnect() {
voiceReconnectAttempts++; voiceReconnectAttempts++;
setVoiceStatus("connecting"); setVoiceStatus("connecting");
teardownVoiceTransport(); teardownVoiceTransport();
//Zmiana sieci = stary RTT dotyczy martwej ścieżki; bez resetu wskaźnik
//wisiałby zamrożony, a fallback HTTP nie miał prawa go nadpisać
voicePingMs = null;
updateVoicePing();
setTimeout(async () => { setTimeout(async () => {
if (!currentVoiceCallId || !currentVoiceCallType) return; if (!currentVoiceCallId || !currentVoiceCallType) return;
try { try {
@ -2617,9 +2706,7 @@ function endVoiceSession() {
micStream = null; micStream = null;
} }
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; } if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; } if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
@ -2629,14 +2716,17 @@ function endVoiceSession() {
voiceReconnectAttempts = 0; voiceReconnectAttempts = 0;
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false; voiceHadRemotePeers = false;
voiceRttFastMs = 0;
voiceRttSamples = [];
voiceRttBaseMs = 0;
voiceAudioHoldUntil = 0;
voiceOutstandingPings.clear();
voiceLastEchoAt = 0;
voiceLastRttSampleAt = 0;
for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){}
}
for (let id in audioDecryptChains) { for (let id in audioDecryptChains) {
if(audioDecryptChains[id]) audioDecryptChains[id].clear(); if(audioDecryptChains[id]) audioDecryptChains[id].clear();
} }
audioDecoders = {};
audioDecryptChains = {}; audioDecryptChains = {};
remoteAudio = {}; remoteAudio = {};
@ -2708,8 +2798,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
screenSenderNode.port.onmessage = async (e) => { screenSenderNode.port.onmessage = async (e) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
// Same backpressure valve as the mic sender. // Ten sam zawór co mikrofon (limit bajtów + korek RTT) - audio
if (currentVoiceWs.bufferedAmount > 256 * 1024) return; // 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 iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -2740,6 +2833,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
}; };
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 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({ const encoder = new VideoEncoder({
output: async (chunk) => { output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
@ -2751,7 +2861,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
}, error: (e) => { voiceLogError('videoencoder', 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 - // Transient capture-mode wobbles must not thrash the encoder -
// commit only after ~300 ms stable. Camera size/fps changes arrive // commit only after ~300 ms stable. Camera size/fps changes arrive
@ -2769,31 +2879,65 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
pendingCfg = newConfig; pendingCfg = newConfig;
pendingAt = performance.now(); pendingAt = performance.now();
} else if (performance.now() - pendingAt > 300) { } else if (performance.now() - pendingAt > 300) {
//Zmiana geometrii źródła - uczony scale łącza zostaje,
//resetujemy tylko redukcję FPS
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null; currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); activeBitrate = Math.round(currentConfig.bitrate * brScale);
fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
applyEncoderBitrate(true);
} }
} else if (pendingCfg) { } else if (pendingCfg) {
pendingCfg = null; // wiggle reverted on its own - ignore pendingCfg = null; // wiggle reverted on its own - ignore
} }
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { //Zawór kolejki socketu (12 KB) ustępuje DALEJ przed progiem audio
let frame = null; //(14 KB) - przy korku najpierw znika obraz, głos płynie dalej.
try { tickCount++;
const nowMs = performance.now(); const policySkip = fpsDiv > 1 && (tickCount % fpsDiv !== 0);
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)}); let skipped = false;
//Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych if (policySkip) {
//klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał //celowe tempo (fpsDiv) - nie liczy się do skipRatio
//się z realnym tempem i keyframes stawały się rzadkie. Widz } else if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 &&
//dołączający w trakcie udostępniania musi zaczekać na keyframe - currentVoiceWs.readyState === WebSocket.OPEN &&
//z licznikiem potrafiło to trwać bardzo długo (efekt "kamery currentVoiceWs.bufferedAmount < VOICE_WS_VIDEO_QUEUE_LIMIT_BYTES && videoEl.readyState >= 2) {
//nie widać"), z zegarem to maksymalnie ~1 s. let frame = null;
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000); try {
encoder.encode(frame, {keyFrame: wantKeyframe}); const nowMs = performance.now();
if (wantKeyframe) lastKeyframeAt = nowMs; frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
} catch (err) { } finally { if (frame) frame.close(); } //Keyframe wg zegara co ~1 s (nie licznika klatek - zawór je pomija)
} const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
setTimeout(encodeLoop, 1000 / currentConfig.fps); 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); setTimeout(encodeLoop, 500);
videoTrack.onended = () => { if(isRunning) stopFunction(); }; videoTrack.onended = () => { if(isRunning) stopFunction(); };