diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css
index a8d2d709..95916d8c 100644
--- a/android/app/src/main/assets/public/style.css
+++ b/android/app/src/main/assets/public/style.css
@@ -686,7 +686,6 @@ space{
flex-shrink: 0;
cursor: pointer;
margin: 0;
- position: relative;
}
.room-pfp {
width: 2.2rem;
@@ -739,7 +738,6 @@ space{
display: flex;
flex-direction: column;
flex-grow: 1;
- min-width: 0;
}
.chat-message-header {
display: flex;
@@ -1180,15 +1178,7 @@ 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;
@@ -1196,6 +1186,7 @@ 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 15e7cea7..ea07e4c2 100644
--- a/android/app/src/main/assets/public/voice.js
+++ b/android/app/src/main/assets/public/voice.js
@@ -1,6 +1,7 @@
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;
@@ -106,8 +107,8 @@ function getVoicePeerSenderKey(clientIdStr) {
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
-let micSourceNode = null, senderNode = null, receiverNode = null;
-let remoteAudio = {}, audioDecryptChains = {};
+let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
+let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
let knownVoiceUsers = new Set();
let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {};
@@ -125,26 +126,22 @@ let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400;
-//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.
+// ===== 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.
const VOICE_PEER_TIMEOUT_MS = 7000;
const VOICE_PING_INTERVAL_MS = 2000;
-const VOICE_SERVER_TIMEOUT_MS = 15000;
-const VOICE_MAX_RECONNECTS = 4;
-const VOICE_ROSTER_POLL_MS = 5000;
+const VOICE_SERVER_TIMEOUT_MS = 10000;
+const VOICE_MAX_RECONNECTS = 2;
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;
@@ -170,21 +167,11 @@ function cleanupPeerMedia(userId) {
function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const iv = nextVoiceIv();
- //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 => {
+ crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).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(() => {});
}
@@ -230,15 +217,9 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
let voiceSettings = loadVoiceSettings();
function loadVoiceSettings() {
- //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 };
+ const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
try {
- 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);
+ return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
} catch (e) {
return defaults;
}
@@ -293,35 +274,6 @@ 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);
@@ -678,11 +630,7 @@ setInterval(() => {
}
// Half-open socket watchdog + backgrounded-tab audio recovery.
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
- //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;
+ const others = voiceActiveUsers.filter(u => u !== String(window.id)).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.
@@ -745,8 +693,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 above target is drained adaptively (proportional to excess, see
- // process()) so latency returns to ~43 ms quickly after network bursts.
+ // Backlog slightly above target is drained by a gentle trim (1 sample per
+ // quantum) instead of letting latency creep up to the hard limit.
constructor(options) {
super();
this.users = {};
@@ -784,12 +732,7 @@ 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) {
- //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;
- }
+ else if (available > 2049) user.readPtr = (user.readPtr + 1) % 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;
@@ -947,38 +890,6 @@ 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)
@@ -1021,7 +932,32 @@ async function startVoiceSession(serverUrl, ticket) {
else btn.classList.remove("voice-btn-danger");
}
- fetchVoiceRoster();
+ const fetchUsers = async () => {
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
+ try {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ const t0 = performance.now();
+ const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
+ const usersRaw = await res.json();
+ voicePingMs = Math.round(performance.now() - t0);
+ updateVoicePing();
+ const rawUsers = usersRaw.map(u => String(u));
+ //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
+ //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
+ for (const gone of [...deadPeers]) {
+ if (!rawUsers.includes(gone)) deadPeers.delete(gone);
+ }
+ const users = rawUsers;
+ let changed = (users.length !== voiceActiveUsers.length);
+ for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
+ if(changed) {
+ voiceActiveUsers = users;
+ updateVoiceUIUsers();
+ }
+ } catch (e) {}
+ };
+ fetchUsers();
//The voice-server roster is joined only AFTER ticket validation (an
//HTTP roundtrip to the main server), so the immediate fetch can still
//miss us - retry quickly until our own tile shows up instead of
@@ -1035,17 +971,13 @@ async function startVoiceSession(serverUrl, ticket) {
voiceRosterRetryTimer = null;
return;
}
- fetchVoiceRoster();
+ fetchUsers();
}, 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ł
@@ -1101,18 +1033,12 @@ 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;
- //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) {
+ // 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) {
vadHangover = 0;
return;
}
@@ -1127,7 +1053,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (db >= sensitivity) vadHangover = 75;
let isSpeaking = vadHangover > 0 && !isMicMuted;
- setTileSpeaking(myIdStr, isSpeaking);
+ setTileSpeaking(String(window.id), isSpeaking);
if (vadHangover > 0 && !isMicMuted) {
vadHangover--;
@@ -1158,44 +1084,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
const type = view.getUint8(8);
- 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 (type === 5) return; // liveness ping - presence already recorded
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
const baseUrl = currentVoiceWs.url.split('/ws')[0];
@@ -1276,7 +1165,6 @@ 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;
@@ -1302,14 +1190,11 @@ 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) {
- voiceLogError('vdecrypt[' + decoderId + ']', err);
- }
+ } catch (err) {}
});
} else if (type === 3) {
(async () => {
@@ -1447,6 +1332,8 @@ 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 });
@@ -2603,6 +2490,7 @@ 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) {
@@ -2619,7 +2507,6 @@ 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 = [];
@@ -2629,12 +2516,6 @@ function teardownVoiceTransport() {
setVoiceRootKey(null);
voiceSendEpoch = null;
voiceSendCounter = 0n;
- voiceRttFastMs = 0;
- voiceRttSamples = [];
- voiceRttBaseMs = 0;
- voiceAudioHoldUntil = 0;
- voiceOutstandingPings.clear();
- voiceLastRttSampleAt = 0;
lastVoiceServerPacketAt = Date.now();
}
@@ -2651,10 +2532,6 @@ 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 {
@@ -2666,7 +2543,7 @@ function scheduleVoiceReconnect() {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();
}
- }, Math.min(4000, 800 * voiceReconnectAttempts));
+ }, 800);
}
function endVoiceSession() {
@@ -2706,27 +2583,24 @@ 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;
- 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) {
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
}
+ audioDecoders = {};
audioDecryptChains = {};
remoteAudio = {};
@@ -2798,11 +2672,8 @@ 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;
- // 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;
+ // Same backpressure valve as the mic sender.
+ if (currentVoiceWs.bufferedAmount > 256 * 1024) 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);
@@ -2832,24 +2703,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
return { width, height, fps, bitrate };
};
- 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) {}
- };
+ let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
const encoder = new VideoEncoder({
output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
@@ -2859,9 +2713,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
const payload = new Uint8Array(14 + cipher.byteLength);
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) => { voiceLogError('videoencoder', e); stopFunction(); }
+ }, error: (e) => { stopFunction(); }
});
- encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
// Transient capture-mode wobbles must not thrash the encoder -
// commit only after ~300 ms stable. Camera size/fps changes arrive
@@ -2879,65 +2733,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
pendingCfg = newConfig;
pendingAt = performance.now();
} else if (performance.now() - pendingAt > 300) {
- //Zmiana geometrii źródła - uczony scale łącza zostaje,
- //resetujemy tylko redukcję FPS
- currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
- activeBitrate = Math.round(currentConfig.bitrate * brScale);
- fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
- applyEncoderBitrate(true);
+ 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'});
}
} else if (pendingCfg) {
pendingCfg = null; // wiggle reverted on its own - ignore
}
- //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);
- };
+ 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);
+ };
setTimeout(encodeLoop, 500);
videoTrack.onended = () => { if(isRunning) stopFunction(); };
diff --git a/webroot/main.js b/webroot/main.js
index 6cf66b66..a5d0ca32 100644
--- a/webroot/main.js
+++ b/webroot/main.js
@@ -4206,55 +4206,6 @@ 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;
@@ -4277,17 +4228,6 @@ 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) => {
@@ -4333,22 +4273,10 @@ 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 8366ba00..63c3484f 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 a8d2d709..95916d8c 100644
--- a/webroot/style.css
+++ b/webroot/style.css
@@ -686,7 +686,6 @@ space{
flex-shrink: 0;
cursor: pointer;
margin: 0;
- position: relative;
}
.room-pfp {
width: 2.2rem;
@@ -739,7 +738,6 @@ space{
display: flex;
flex-direction: column;
flex-grow: 1;
- min-width: 0;
}
.chat-message-header {
display: flex;
@@ -1180,15 +1178,7 @@ 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;
@@ -1196,6 +1186,7 @@ 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 15e7cea7..ea07e4c2 100644
--- a/webroot/voice.js
+++ b/webroot/voice.js
@@ -1,6 +1,7 @@
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;
@@ -106,8 +107,8 @@ function getVoicePeerSenderKey(clientIdStr) {
let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
-let micSourceNode = null, senderNode = null, receiverNode = null;
-let remoteAudio = {}, audioDecryptChains = {};
+let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
+let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
let knownVoiceUsers = new Set();
let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {};
@@ -125,26 +126,22 @@ let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400;
-//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.
+// ===== 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.
const VOICE_PEER_TIMEOUT_MS = 7000;
const VOICE_PING_INTERVAL_MS = 2000;
-const VOICE_SERVER_TIMEOUT_MS = 15000;
-const VOICE_MAX_RECONNECTS = 4;
-const VOICE_ROSTER_POLL_MS = 5000;
+const VOICE_SERVER_TIMEOUT_MS = 10000;
+const VOICE_MAX_RECONNECTS = 2;
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;
@@ -170,21 +167,11 @@ function cleanupPeerMedia(userId) {
function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const iv = nextVoiceIv();
- //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 => {
+ crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).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(() => {});
}
@@ -230,15 +217,9 @@ const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
let voiceSettings = loadVoiceSettings();
function loadVoiceSettings() {
- //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 };
+ const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
try {
- 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);
+ return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
} catch (e) {
return defaults;
}
@@ -293,35 +274,6 @@ 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);
@@ -678,11 +630,7 @@ setInterval(() => {
}
// Half-open socket watchdog + backgrounded-tab audio recovery.
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
- //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;
+ const others = voiceActiveUsers.filter(u => u !== String(window.id)).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.
@@ -745,8 +693,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 above target is drained adaptively (proportional to excess, see
- // process()) so latency returns to ~43 ms quickly after network bursts.
+ // Backlog slightly above target is drained by a gentle trim (1 sample per
+ // quantum) instead of letting latency creep up to the hard limit.
constructor(options) {
super();
this.users = {};
@@ -784,12 +732,7 @@ 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) {
- //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;
- }
+ else if (available > 2049) user.readPtr = (user.readPtr + 1) % 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;
@@ -947,38 +890,6 @@ 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)
@@ -1021,7 +932,32 @@ async function startVoiceSession(serverUrl, ticket) {
else btn.classList.remove("voice-btn-danger");
}
- fetchVoiceRoster();
+ const fetchUsers = async () => {
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
+ try {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ const t0 = performance.now();
+ const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
+ const usersRaw = await res.json();
+ voicePingMs = Math.round(performance.now() - t0);
+ updateVoicePing();
+ const rawUsers = usersRaw.map(u => String(u));
+ //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
+ //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
+ for (const gone of [...deadPeers]) {
+ if (!rawUsers.includes(gone)) deadPeers.delete(gone);
+ }
+ const users = rawUsers;
+ let changed = (users.length !== voiceActiveUsers.length);
+ for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
+ if(changed) {
+ voiceActiveUsers = users;
+ updateVoiceUIUsers();
+ }
+ } catch (e) {}
+ };
+ fetchUsers();
//The voice-server roster is joined only AFTER ticket validation (an
//HTTP roundtrip to the main server), so the immediate fetch can still
//miss us - retry quickly until our own tile shows up instead of
@@ -1035,17 +971,13 @@ async function startVoiceSession(serverUrl, ticket) {
voiceRosterRetryTimer = null;
return;
}
- fetchVoiceRoster();
+ fetchUsers();
}, 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ł
@@ -1101,18 +1033,12 @@ 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;
- //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) {
+ // 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) {
vadHangover = 0;
return;
}
@@ -1127,7 +1053,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (db >= sensitivity) vadHangover = 75;
let isSpeaking = vadHangover > 0 && !isMicMuted;
- setTileSpeaking(myIdStr, isSpeaking);
+ setTileSpeaking(String(window.id), isSpeaking);
if (vadHangover > 0 && !isMicMuted) {
vadHangover--;
@@ -1158,44 +1084,7 @@ async function startVoiceSession(serverUrl, ticket) {
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
const type = view.getUint8(8);
- 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 (type === 5) return; // liveness ping - presence already recorded
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
const baseUrl = currentVoiceWs.url.split('/ws')[0];
@@ -1276,7 +1165,6 @@ 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;
@@ -1302,14 +1190,11 @@ 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) {
- voiceLogError('vdecrypt[' + decoderId + ']', err);
- }
+ } catch (err) {}
});
} else if (type === 3) {
(async () => {
@@ -1447,6 +1332,8 @@ 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 });
@@ -2603,6 +2490,7 @@ 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) {
@@ -2619,7 +2507,6 @@ 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 = [];
@@ -2629,12 +2516,6 @@ function teardownVoiceTransport() {
setVoiceRootKey(null);
voiceSendEpoch = null;
voiceSendCounter = 0n;
- voiceRttFastMs = 0;
- voiceRttSamples = [];
- voiceRttBaseMs = 0;
- voiceAudioHoldUntil = 0;
- voiceOutstandingPings.clear();
- voiceLastRttSampleAt = 0;
lastVoiceServerPacketAt = Date.now();
}
@@ -2651,10 +2532,6 @@ 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 {
@@ -2666,7 +2543,7 @@ function scheduleVoiceReconnect() {
console.warn("Voice reconnect failed", err);
scheduleVoiceReconnect();
}
- }, Math.min(4000, 800 * voiceReconnectAttempts));
+ }, 800);
}
function endVoiceSession() {
@@ -2706,27 +2583,24 @@ 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;
- 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) {
if(audioDecryptChains[id]) audioDecryptChains[id].clear();
}
+ audioDecoders = {};
audioDecryptChains = {};
remoteAudio = {};
@@ -2798,11 +2672,8 @@ 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;
- // 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;
+ // Same backpressure valve as the mic sender.
+ if (currentVoiceWs.bufferedAmount > 256 * 1024) 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);
@@ -2832,24 +2703,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
return { width, height, fps, bitrate };
};
- 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) {}
- };
+ let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
const encoder = new VideoEncoder({
output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
@@ -2859,9 +2713,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
const payload = new Uint8Array(14 + cipher.byteLength);
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) => { voiceLogError('videoencoder', e); stopFunction(); }
+ }, error: (e) => { stopFunction(); }
});
- encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: activeBitrate, latencyMode: 'realtime'});
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
// Transient capture-mode wobbles must not thrash the encoder -
// commit only after ~300 ms stable. Camera size/fps changes arrive
@@ -2879,65 +2733,23 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
pendingCfg = newConfig;
pendingAt = performance.now();
} else if (performance.now() - pendingAt > 300) {
- //Zmiana geometrii źródła - uczony scale łącza zostaje,
- //resetujemy tylko redukcję FPS
- currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
- activeBitrate = Math.round(currentConfig.bitrate * brScale);
- fpsDiv = 1; lowStreak = 0; cleanStreak = 0;
- applyEncoderBitrate(true);
+ 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'});
}
} else if (pendingCfg) {
pendingCfg = null; // wiggle reverted on its own - ignore
}
- //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);
- };
+ 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);
+ };
setTimeout(encodeLoop, 500);
videoTrack.onended = () => { if(isRunning) stopFunction(); };