diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index dded8edb..a5d0ca32 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) { } async function getAvatarUrl(id, username) { + //S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy + //generowało 1-2 żądania NA wiersz (główny składnik N+1). + //Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy + //network itp.) nie mogą przyklejać fallback-SVG na minutę + if (!window.pfpExistenceCache) window.pfpExistenceCache = {}; + const cached = window.pfpExistenceCache[id]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(username); @@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) { } async function getGroupAvatarUrl(groupId, groupName) { + if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {}; + const cached = window.groupPfpExistenceCache[groupId]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(groupName); @@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) { await delay(200); - roomsTopBarTransition.innerHTML = processBlah(title); + roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title)); let parser = new DOMParser(); @@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let clickableContent = showRoomBar ? `` : ` - ${icon ? icon + '' : ''}${processBlah(title)} + ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2033,7 +2045,9 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - let mappedRole = "role." + roleRes.replace(" ", "_"); + //roleRes trafia do innerHTML - whitelist znaków zamiast surowej + //podstawienia (audyt W6: serwer-controlled string w szablonie) + let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_"); roleHtml = `
${mappedRole}
`; } } @@ -2275,7 +2289,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", groupName) + .replaceAll("{username}", escapeHtmlText(groupName)) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2644,7 +2658,13 @@ function handleMobileSwipe() { } } +//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups +//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em +let dmListRenderGen = 0; +let groupListRenderGen = 0; + async function renderDms(res) { + const gen = ++dmListRenderGen; let container = document.getElementById("dms-list"); if (!container) return; @@ -2665,8 +2685,13 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); - if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze + let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!targetUsername) { + targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername; + } let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2694,11 +2719,13 @@ async function renderDms(res) { count++; } + if (gen !== dmListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); updateVoicePresenceUI(); } else { + if (gen !== dmListRenderGen) return; container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2716,7 +2743,17 @@ async function renderDms(res) { } } +//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają, +//a group/info było najdroższym elementem renderu listy grup +function getGroupNameFromCache(groupId) { + if (!window.groupNameCache) return null; + let entry = window.groupNameCache[sanitizeId(groupId)]; + if (!entry || Date.now() - entry.ts > 60000) return null; + return entry.name; +} + async function renderGroups(res) { + const gen = ++groupListRenderGen; let container = document.getElementById("groups-list"); if (!container) return; @@ -2737,11 +2774,24 @@ async function renderGroups(res) { seedCallStateFromList(groupId, groupData.call === true); - let infoRes = await fetchEncrypted("group/info", groupId); - if (infoRes && infoRes.startsWith("{")) { - let info = JSON.parse(infoRes); - let groupName = info.string2; + //S22: TTL-cache nazw grup - group/info było wołane per grupa per render + let groupName = getGroupNameFromCache(groupId); + if (groupName === null) { + let infoRes = await fetchEncrypted("group/info", groupId); + if (infoRes && infoRes.startsWith("{")) { + groupName = JSON.parse(infoRes).string2; + if (!window.groupNameCache) window.groupNameCache = {}; + window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() }; + } else { + if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); + } + continue; + } + } + { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); @@ -2760,17 +2810,16 @@ async function renderGroups(res) { `; count++; - } else if (infoRes === "error:group.not.found") { - //grupa została usunięta - wyczyść nieaktualny wpis z listy - removeRoomFromCachedList(groupId, 'groups'); } } + if (gen !== groupListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); updateVoicePresenceUI(); } else { + if (gen !== groupListRenderGen) return; container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 9ca9bbd3..b7ba7806 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -137,6 +137,9 @@ 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 lastVoiceKeyRequestAt = 0; @@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) { delete remoteAudio[aid]; delete audioDecryptChains[aid]; } + if (remoteOpusDecoders[aid]) { + try { remoteOpusDecoders[aid].close(); } catch (e) {} + delete remoteOpusDecoders[aid]; + } } for (const decoderId of Object.keys(remoteVideos)) { if (decoderId.split('-')[0] === userId) { @@ -544,8 +551,10 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - voiceConnectedAt = null; - if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie + //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy + //realnym rozłączeniu (disconnected z endVoiceSession) + if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -605,7 +614,11 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. + // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego + // podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych + // znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem + // prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem. + // deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty". let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -617,12 +630,18 @@ setInterval(() => { } } if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { 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. + // Bez tego resetu join drugiej osoby natychmiast spełniał warunek + // ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm). + if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now; + voiceHadRemotePeers = others > 0; // Alive peers ping every 2s regardless of mute, so total inbound // silence while others are present means the socket is dead. if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { @@ -753,6 +772,96 @@ class TaskQueue { let voiceJoinInFlight = false; +// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM === +// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu - +// wymaga testów na realnych urządzeniach przed ponownym włączeniem). +// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje. +const ENABLE_VOICE_OPUS = false; +// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak +// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak +// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek +// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują). +let voiceOpusEncoder = null; +let voiceOpusEncoderFailed = false; +let voiceOpusInputTimestampUs = 0; +const remoteOpusDecoders = {}; + +function getVoiceOpusEncoder() { + if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null; + if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; } + if (voiceOpusEncoder) return voiceOpusEncoder; + try { + voiceOpusEncoder = new AudioEncoder({ + output: (chunk) => { + try { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; + const bytes = new Uint8Array(chunk.byteLength); + chunk.copyTo(bytes); + sendVoiceEncodedFrame(6, bytes); + } catch (e) {} + }, + error: () => { voiceOpusEncoderFailed = true; } + }); + voiceOpusEncoder.configure({ + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 1, + bitrate: 32000 + }); + return voiceOpusEncoder; + } catch (e) { + voiceOpusEncoderFailed = true; + voiceOpusEncoder = null; + return null; + } +} + +async function sendVoiceEncodedFrame(type, plainBytes) { + //licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik + //unikalności IV bez zmian + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes); + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; + const payload = new Uint8Array(14 + cipher.byteLength); + payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); + currentVoiceWs.send(payload.buffer); +} + +function decodeVoiceOpus(audioId, targetNode, opusBuffer) { + try { + if (typeof window.AudioDecoder !== 'function') return; + let dec = remoteOpusDecoders[audioId]; + if (!dec) { + dec = new AudioDecoder({ + output: (audioData) => { + try { + const frames = audioData.numberOfFrames; + const f32 = new Float32Array(frames); + audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' }); + const pcm = new Int16Array(frames); + for (let i = 0; i < frames; i++) { + const s = Math.max(-1, Math.min(1, f32[i])); + pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + audioData.close(); + if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]); + } catch (e) { try { audioData.close(); } catch (e2) {} } + }, + error: () => { delete remoteOpusDecoders[audioId]; } + }); + dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 }); + remoteOpusDecoders[audioId] = dec; + } + dec.decode(new EncodedAudioChunk({ + type: 'key', + timestamp: Math.trunc(performance.now() * 1000), + data: opusBuffer + })); + } catch (err) { + delete remoteOpusDecoders[audioId]; + } +} + async function initiateVoiceCall(roomId = null) { if (currentVoiceWs || voiceJoinInFlight) { showBlahNotification("error:already.in.call"); @@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) { if (joined) { await startVoiceSession(joined.serverUrl, joined.ticket); + } else { + //S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/ + //Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie + //"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy + currentVoiceCallId = null; + currentVoiceCallType = null; } } finally { voiceJoinInFlight = false; @@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. + //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.filter(u => !deadPeers.has(u)); + const users = rawUsers; let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić + //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał + //"connected" zombie-call ze spalonym single-use ticketem i bez + //mikrofonu, bez powiadomienia i bez recovery + try { + // Reconnects reuse the live mic stream - only capture a new one + // when the previous is gone (full teardown or track ended). + if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { + micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); + if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } + } - const nativeRouting = getNativeVoiceRouting(); - if (nativeRouting && nativeRouting.startCall) { - try { await nativeRouting.startCall(); } catch (e) {} - } + const nativeRouting = getNativeVoiceRouting(); + if (nativeRouting && nativeRouting.startCall) { + try { await nativeRouting.startCall(); } catch (e) {} + } - if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); - if (!isAudioWorkletLoaded) { - await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); - senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); - const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); - receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); - screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); - isAudioWorkletLoaded = true; + if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); + if (!isAudioWorkletLoaded) { + await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); + senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); + const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); + receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); + screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); + isAudioWorkletLoaded = true; + } + if (audioContext.state === 'suspended') await audioContext.resume(); + applyVoiceSpeakerSink(); + setupVoiceOutput(); + + if (micSourceNode) { + try { micSourceNode.disconnect(); } catch (e) {} + micSourceNode = null; + } + micSourceNode = audioContext.createMediaStreamSource(micStream); + micSourceNode.connect(senderNode); + } catch (err) { + console.error("Voice: call setup failed", err); + showBlahNotification("error:call.connect.failed"); + endVoiceSession(); + return; } - if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); - setupVoiceOutput(); - - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { @@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) { if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (!voiceMySenderCryptoKey) return; + + //S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM); + //każda awaria = spójny fallback na legacy PCM poniżej + const opusEnc = getVoiceOpusEncoder(); + if (opusEnc && opusEnc.state === 'configured') { + try { + const i16 = new Int16Array(audioData); + const f32 = new Float32Array(i16.length); + for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768; + const ad = new AudioData({ + format: 'f32-planar', + sampleRate: 48000, + numberOfFrames: i16.length, + numberOfChannels: 1, + timestamp: voiceOpusInputTimestampUs, + data: f32 + }); + voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000); + opusEnc.encode(ad); + ad.close(); + return; + } catch (e) { /* fallback na PCM */ } + } + const iv = nextVoiceIv(); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) { //podklucz nadawcy - deterministyczny z (root, clientId) const senderKeyPromise = getVoicePeerSenderKey(clientId); - if (type === 0 || type === 4) { + if (type === 0 || type === 4 || type === 6) { const isScreenAudio = (type === 4); + const isOpus = (type === 6); const audioId = isScreenAudio ? `${clientId}_screen` : clientId; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; remoteAudio[audioId].lastUpdate = Date.now(); @@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) { const senderKey = await senderKeyPromise; if (!senderKey) return; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); - if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); + if (isOpus) { + //S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu + decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer); + } else { + if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); + } setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); } catch (err) {} @@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) { } } - //Ignore updates for rooms we have nothing to do with - they would otherwise - //overwrite the participant list of an ongoing call and wipe its tiles. - if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; - let state = incomingState.filter(u => !deadPeers.has(u)); + //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa + //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować + //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju + if (roomId !== currentVoiceCallId) return; + let state = incomingState; voiceActiveUsers = state; if (currentVoiceCallId === roomId) { @@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() { let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; if (!avatarRes && window.getAvatarUrl) { - getAvatarUrl(u, authorName || "User").then(url => { + //Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi + //(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User" + const nameForAvatar = (authorName && authorName !== u) ? authorName : "User"; + getAvatarUrl(u, nameForAvatar).then(url => { if(url) { window.dmPfpCache[u] = url; let el = document.getElementById(`voice-tile-${u}`); @@ -2492,6 +2653,15 @@ function endVoiceSession() { deadPeers.clear(); lastVoiceKeyRequestAt = 0; voiceActiveUsers = []; + voiceHadRemotePeers = false; + + //S20: teardown enkodera/dekoderów opus + if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; } + voiceOpusInputTimestampUs = 0; + for (const aid of Object.keys(remoteOpusDecoders)) { + try { remoteOpusDecoders[aid].close(); } catch (e) {} + delete remoteOpusDecoders[aid]; + } for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} diff --git a/webroot/main.js b/webroot/main.js index dded8edb..a5d0ca32 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) { } async function getAvatarUrl(id, username) { + //S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy + //generowało 1-2 żądania NA wiersz (główny składnik N+1). + //Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy + //network itp.) nie mogą przyklejać fallback-SVG na minutę + if (!window.pfpExistenceCache) window.pfpExistenceCache = {}; + const cached = window.pfpExistenceCache[id]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(username); @@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) { } async function getGroupAvatarUrl(groupId, groupName) { + if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {}; + const cached = window.groupPfpExistenceCache[groupId]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(groupName); @@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) { await delay(200); - roomsTopBarTransition.innerHTML = processBlah(title); + roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title)); let parser = new DOMParser(); @@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let clickableContent = showRoomBar ? `` : ` - ${icon ? icon + '' : ''}${processBlah(title)} + ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2033,7 +2045,9 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - let mappedRole = "role." + roleRes.replace(" ", "_"); + //roleRes trafia do innerHTML - whitelist znaków zamiast surowej + //podstawienia (audyt W6: serwer-controlled string w szablonie) + let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_"); roleHtml = `
${mappedRole}
`; } } @@ -2275,7 +2289,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", groupName) + .replaceAll("{username}", escapeHtmlText(groupName)) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2644,7 +2658,13 @@ function handleMobileSwipe() { } } +//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups +//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em +let dmListRenderGen = 0; +let groupListRenderGen = 0; + async function renderDms(res) { + const gen = ++dmListRenderGen; let container = document.getElementById("dms-list"); if (!container) return; @@ -2665,8 +2685,13 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); - if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze + let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!targetUsername) { + targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername; + } let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2694,11 +2719,13 @@ async function renderDms(res) { count++; } + if (gen !== dmListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); updateVoicePresenceUI(); } else { + if (gen !== dmListRenderGen) return; container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2716,7 +2743,17 @@ async function renderDms(res) { } } +//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają, +//a group/info było najdroższym elementem renderu listy grup +function getGroupNameFromCache(groupId) { + if (!window.groupNameCache) return null; + let entry = window.groupNameCache[sanitizeId(groupId)]; + if (!entry || Date.now() - entry.ts > 60000) return null; + return entry.name; +} + async function renderGroups(res) { + const gen = ++groupListRenderGen; let container = document.getElementById("groups-list"); if (!container) return; @@ -2737,11 +2774,24 @@ async function renderGroups(res) { seedCallStateFromList(groupId, groupData.call === true); - let infoRes = await fetchEncrypted("group/info", groupId); - if (infoRes && infoRes.startsWith("{")) { - let info = JSON.parse(infoRes); - let groupName = info.string2; + //S22: TTL-cache nazw grup - group/info było wołane per grupa per render + let groupName = getGroupNameFromCache(groupId); + if (groupName === null) { + let infoRes = await fetchEncrypted("group/info", groupId); + if (infoRes && infoRes.startsWith("{")) { + groupName = JSON.parse(infoRes).string2; + if (!window.groupNameCache) window.groupNameCache = {}; + window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() }; + } else { + if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); + } + continue; + } + } + { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); @@ -2760,17 +2810,16 @@ async function renderGroups(res) { `; count++; - } else if (infoRes === "error:group.not.found") { - //grupa została usunięta - wyczyść nieaktualny wpis z listy - removeRoomFromCachedList(groupId, 'groups'); } } + if (gen !== groupListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); updateVoicePresenceUI(); } else { + if (gen !== groupListRenderGen) return; container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); diff --git a/webroot/voice.js b/webroot/voice.js index 9ca9bbd3..b7ba7806 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -137,6 +137,9 @@ 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 lastVoiceKeyRequestAt = 0; @@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) { delete remoteAudio[aid]; delete audioDecryptChains[aid]; } + if (remoteOpusDecoders[aid]) { + try { remoteOpusDecoders[aid].close(); } catch (e) {} + delete remoteOpusDecoders[aid]; + } } for (const decoderId of Object.keys(remoteVideos)) { if (decoderId.split('-')[0] === userId) { @@ -544,8 +551,10 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - voiceConnectedAt = null; - if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie + //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy + //realnym rozłączeniu (disconnected z endVoiceSession) + if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -605,7 +614,11 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. + // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego + // podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych + // znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem + // prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem. + // deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty". let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -617,12 +630,18 @@ setInterval(() => { } } if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { 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. + // Bez tego resetu join drugiej osoby natychmiast spełniał warunek + // ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm). + if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now; + voiceHadRemotePeers = others > 0; // Alive peers ping every 2s regardless of mute, so total inbound // silence while others are present means the socket is dead. if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { @@ -753,6 +772,96 @@ class TaskQueue { let voiceJoinInFlight = false; +// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM === +// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu - +// wymaga testów na realnych urządzeniach przed ponownym włączeniem). +// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje. +const ENABLE_VOICE_OPUS = false; +// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak +// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak +// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek +// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują). +let voiceOpusEncoder = null; +let voiceOpusEncoderFailed = false; +let voiceOpusInputTimestampUs = 0; +const remoteOpusDecoders = {}; + +function getVoiceOpusEncoder() { + if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null; + if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; } + if (voiceOpusEncoder) return voiceOpusEncoder; + try { + voiceOpusEncoder = new AudioEncoder({ + output: (chunk) => { + try { + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; + const bytes = new Uint8Array(chunk.byteLength); + chunk.copyTo(bytes); + sendVoiceEncodedFrame(6, bytes); + } catch (e) {} + }, + error: () => { voiceOpusEncoderFailed = true; } + }); + voiceOpusEncoder.configure({ + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 1, + bitrate: 32000 + }); + return voiceOpusEncoder; + } catch (e) { + voiceOpusEncoderFailed = true; + voiceOpusEncoder = null; + return null; + } +} + +async function sendVoiceEncodedFrame(type, plainBytes) { + //licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik + //unikalności IV bez zmian + const iv = nextVoiceIv(); + const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes); + if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; + const payload = new Uint8Array(14 + cipher.byteLength); + payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); + currentVoiceWs.send(payload.buffer); +} + +function decodeVoiceOpus(audioId, targetNode, opusBuffer) { + try { + if (typeof window.AudioDecoder !== 'function') return; + let dec = remoteOpusDecoders[audioId]; + if (!dec) { + dec = new AudioDecoder({ + output: (audioData) => { + try { + const frames = audioData.numberOfFrames; + const f32 = new Float32Array(frames); + audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' }); + const pcm = new Int16Array(frames); + for (let i = 0; i < frames; i++) { + const s = Math.max(-1, Math.min(1, f32[i])); + pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + audioData.close(); + if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]); + } catch (e) { try { audioData.close(); } catch (e2) {} } + }, + error: () => { delete remoteOpusDecoders[audioId]; } + }); + dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 }); + remoteOpusDecoders[audioId] = dec; + } + dec.decode(new EncodedAudioChunk({ + type: 'key', + timestamp: Math.trunc(performance.now() * 1000), + data: opusBuffer + })); + } catch (err) { + delete remoteOpusDecoders[audioId]; + } +} + async function initiateVoiceCall(roomId = null) { if (currentVoiceWs || voiceJoinInFlight) { showBlahNotification("error:already.in.call"); @@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) { if (joined) { await startVoiceSession(joined.serverUrl, joined.ticket); + } else { + //S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/ + //Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie + //"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy + currentVoiceCallId = null; + currentVoiceCallType = null; } } finally { voiceJoinInFlight = false; @@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. + //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.filter(u => !deadPeers.has(u)); + const users = rawUsers; let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić + //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał + //"connected" zombie-call ze spalonym single-use ticketem i bez + //mikrofonu, bez powiadomienia i bez recovery + try { + // Reconnects reuse the live mic stream - only capture a new one + // when the previous is gone (full teardown or track ended). + if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { + micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); + if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } + } - const nativeRouting = getNativeVoiceRouting(); - if (nativeRouting && nativeRouting.startCall) { - try { await nativeRouting.startCall(); } catch (e) {} - } + const nativeRouting = getNativeVoiceRouting(); + if (nativeRouting && nativeRouting.startCall) { + try { await nativeRouting.startCall(); } catch (e) {} + } - if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); - if (!isAudioWorkletLoaded) { - await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); - senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); - const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); - receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); - screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); - isAudioWorkletLoaded = true; + if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); + if (!isAudioWorkletLoaded) { + await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); + senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); + const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); + receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); + screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); + isAudioWorkletLoaded = true; + } + if (audioContext.state === 'suspended') await audioContext.resume(); + applyVoiceSpeakerSink(); + setupVoiceOutput(); + + if (micSourceNode) { + try { micSourceNode.disconnect(); } catch (e) {} + micSourceNode = null; + } + micSourceNode = audioContext.createMediaStreamSource(micStream); + micSourceNode.connect(senderNode); + } catch (err) { + console.error("Voice: call setup failed", err); + showBlahNotification("error:call.connect.failed"); + endVoiceSession(); + return; } - if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); - setupVoiceOutput(); - - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { @@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) { if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (!voiceMySenderCryptoKey) return; + + //S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM); + //każda awaria = spójny fallback na legacy PCM poniżej + const opusEnc = getVoiceOpusEncoder(); + if (opusEnc && opusEnc.state === 'configured') { + try { + const i16 = new Int16Array(audioData); + const f32 = new Float32Array(i16.length); + for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768; + const ad = new AudioData({ + format: 'f32-planar', + sampleRate: 48000, + numberOfFrames: i16.length, + numberOfChannels: 1, + timestamp: voiceOpusInputTimestampUs, + data: f32 + }); + voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000); + opusEnc.encode(ad); + ad.close(); + return; + } catch (e) { /* fallback na PCM */ } + } + const iv = nextVoiceIv(); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) { //podklucz nadawcy - deterministyczny z (root, clientId) const senderKeyPromise = getVoicePeerSenderKey(clientId); - if (type === 0 || type === 4) { + if (type === 0 || type === 4 || type === 6) { const isScreenAudio = (type === 4); + const isOpus = (type === 6); const audioId = isScreenAudio ? `${clientId}_screen` : clientId; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; remoteAudio[audioId].lastUpdate = Date.now(); @@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) { const senderKey = await senderKeyPromise; if (!senderKey) return; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); - if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); + if (isOpus) { + //S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu + decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer); + } else { + if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); + } setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); } catch (err) {} @@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) { } } - //Ignore updates for rooms we have nothing to do with - they would otherwise - //overwrite the participant list of an ongoing call and wipe its tiles. - if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; - let state = incomingState.filter(u => !deadPeers.has(u)); + //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa + //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować + //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju + if (roomId !== currentVoiceCallId) return; + let state = incomingState; voiceActiveUsers = state; if (currentVoiceCallId === roomId) { @@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() { let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; if (!avatarRes && window.getAvatarUrl) { - getAvatarUrl(u, authorName || "User").then(url => { + //Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi + //(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User" + const nameForAvatar = (authorName && authorName !== u) ? authorName : "User"; + getAvatarUrl(u, nameForAvatar).then(url => { if(url) { window.dmPfpCache[u] = url; let el = document.getElementById(`voice-tile-${u}`); @@ -2492,6 +2653,15 @@ function endVoiceSession() { deadPeers.clear(); lastVoiceKeyRequestAt = 0; voiceActiveUsers = []; + voiceHadRemotePeers = false; + + //S20: teardown enkodera/dekoderów opus + if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; } + voiceOpusInputTimestampUs = 0; + for (const aid of Object.keys(remoteOpusDecoders)) { + try { remoteOpusDecoders[aid].close(); } catch (e) {} + delete remoteOpusDecoders[aid]; + } for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){}