remove broken opus implementation

This commit is contained in:
olcxja 2026-08-26 00:43:38 +02:00
commit 6f83368b06
2 changed files with 134 additions and 272 deletions

View file

@ -153,10 +153,6 @@ function cleanupPeerMedia(userId) {
delete remoteAudio[aid]; delete remoteAudio[aid];
delete audioDecryptChains[aid]; delete audioDecryptChains[aid];
} }
if (remoteOpusDecoders[aid]) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
} }
for (const decoderId of Object.keys(remoteVideos)) { for (const decoderId of Object.keys(remoteVideos)) {
if (decoderId.split('-')[0] === userId) { if (decoderId.split('-')[0] === userId) {
@ -647,8 +643,13 @@ setInterval(() => {
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
console.warn("Voice: no inbound traffic, forcing reconnect"); console.warn("Voice: no inbound traffic, forcing reconnect");
try { currentVoiceWs.close(); } catch (e) {} try { currentVoiceWs.close(); } catch (e) {}
} else if (audioContext && audioContext.state === 'suspended') { } else if (audioContext && audioContext.state !== 'running') {
//Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy
//stan != running przy żywej rozmowie wymaga próby wznowienia.
//Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony
//(armVoiceContextKickstart sam jest idempotentny)
audioContext.resume().catch(() => {}); audioContext.resume().catch(() => {});
armVoiceContextKickstart();
} }
// Safety net for the call-key handshake: if others are in the call but // Safety net for the call-key handshake: if others are in the call but
// we never received the root key (raced/lost signal), ask for it // we never received the root key (raced/lost signal), ask for it
@ -772,94 +773,37 @@ class TaskQueue {
let voiceJoinInFlight = false; let voiceJoinInFlight = false;
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM === //Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu - //problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach)
// wymaga testów na realnych urządzeniach przed ponownym włączeniem). let voiceErrorLogsLeft = 6;
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje. function voiceLogError(where, err) {
const ENABLE_VOICE_OPUS = false; if (voiceErrorLogsLeft > 0) {
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak voiceErrorLogsLeft--;
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak console.warn("Voice: error [" + where + "]", err);
// 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) { //Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik //może zablokować kontekst stworzony po async-owych krokach dołączania do
//unikalności IV bez zmian //rozmowy). Listenery są one-shot i zdejmują się nawzajem.
const iv = nextVoiceIv(); let voiceCtxKickstartHandler = null;
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes); function armVoiceContextKickstart() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!audioContext) return;
const payload = new Uint8Array(14 + cipher.byteLength); if (voiceCtxKickstartHandler) return; //już uzbrojone
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); voiceCtxKickstartHandler = () => {
currentVoiceWs.send(payload.buffer); audioContext.resume({}).then(() => {
} if (audioContext.state === 'running') {
console.warn("Voice: AudioContext resumed after user gesture");
function decodeVoiceOpus(audioId, targetNode, opusBuffer) { document.removeEventListener('click', voiceCtxKickstartHandler);
try { document.removeEventListener('touchend', voiceCtxKickstartHandler);
if (typeof window.AudioDecoder !== 'function') return; document.removeEventListener('keydown', voiceCtxKickstartHandler);
let dec = remoteOpusDecoders[audioId]; voiceCtxKickstartHandler = null;
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];
} }
}).catch(() => {});
};
document.addEventListener('click', voiceCtxKickstartHandler);
document.addEventListener('touchend', voiceCtxKickstartHandler);
document.addEventListener('keydown', voiceCtxKickstartHandler);
} }
async function initiateVoiceCall(roomId = null) { async function initiateVoiceCall(roomId = null) {
@ -1061,7 +1005,17 @@ async function startVoiceSession(serverUrl, ticket) {
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
} }
if (audioContext.state === 'suspended') await audioContext.resume(); if (audioContext.state !== 'running') {
try { await audioContext.resume(); } catch (e) {}
}
if (audioContext.state !== 'running') {
//Polityka autoplay / stan "interrupted": kontekst nie wstał
//samoaktualizacyjnie. Bez tego cisza w OBIE strony (mikrofon
//i głośnik żyją w tym samym kontekście), mimo że wskaźniki
//mówienia i screen share działają (nie potrzebują kontekstu).
console.warn("Voice: AudioContext not running after open (" + audioContext.state + ") - arming user-gesture kickstart");
armVoiceContextKickstart();
}
applyVoiceSpeakerSink(); applyVoiceSpeakerSink();
setupVoiceOutput(); setupVoiceOutput();
@ -1107,30 +1061,6 @@ async function startVoiceSession(serverUrl, ticket) {
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; 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 iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -1175,9 +1105,8 @@ async function startVoiceSession(serverUrl, ticket) {
//podklucz nadawcy - deterministyczny z (root, clientId) //podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId); const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4 || type === 6) { if (type === 0 || type === 4) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
const isOpus = (type === 6);
const audioId = isScreenAudio ? `${clientId}_screen` : clientId; const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
remoteAudio[audioId].lastUpdate = Date.now(); remoteAudio[audioId].lastUpdate = Date.now();
@ -1188,17 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) {
audioDecryptChains[audioId].enqueue(async () => { audioDecryptChains[audioId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const senderKey = await senderKeyPromise;
if (!senderKey) return; if (!senderKey) {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
if (isOpus) { return;
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
} else {
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
} }
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {} } catch (err) {
voiceLogError('media.decrypt[' + audioId + ']', err);
}
}); });
} else if (type === 1 || type === 2) { } else if (type === 1 || type === 2) {
const decoderId = `${clientId}-${type}`; const decoderId = `${clientId}-${type}`;
@ -1388,6 +1317,15 @@ async function handleCallUpdate(data) {
for (let u of state) { for (let u of state) {
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
} }
} else if (state.length > 0 && Date.now() - lastVoiceKeyRequestAt > 1500) {
//Jesteśmy w rozmowie BEZ klucza (np. dołączyliśmy do trwającej) -
//nie czekamy na tick watchdoga (do 4 s), prosimy natychmiast,
//inaczej pierwsze sekundy rozmowy są u nas i u nich ciszą
lastVoiceKeyRequestAt = Date.now();
console.warn("Voice: joined keyless - requesting call key now");
for (const u of state) {
if (u !== String(window.id)) sendVoiceSignalToUser(u, roomId, true);
}
} }
updateVoiceUIUsers(); updateVoiceUIUsers();
@ -1586,6 +1524,7 @@ async function handleRoomSignal(data) {
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
await setVoiceRootKey(base64ToUint8(decRoomKeyB64)); await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
} }
} catch(e) { } catch(e) {
console.error("Signal decryption failed", e); console.error("Signal decryption failed", e);
@ -2655,14 +2594,6 @@ function endVoiceSession() {
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false; 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) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}
} }

View file

@ -153,10 +153,6 @@ function cleanupPeerMedia(userId) {
delete remoteAudio[aid]; delete remoteAudio[aid];
delete audioDecryptChains[aid]; delete audioDecryptChains[aid];
} }
if (remoteOpusDecoders[aid]) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
} }
for (const decoderId of Object.keys(remoteVideos)) { for (const decoderId of Object.keys(remoteVideos)) {
if (decoderId.split('-')[0] === userId) { if (decoderId.split('-')[0] === userId) {
@ -647,8 +643,13 @@ setInterval(() => {
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
console.warn("Voice: no inbound traffic, forcing reconnect"); console.warn("Voice: no inbound traffic, forcing reconnect");
try { currentVoiceWs.close(); } catch (e) {} try { currentVoiceWs.close(); } catch (e) {}
} else if (audioContext && audioContext.state === 'suspended') { } else if (audioContext && audioContext.state !== 'running') {
//Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy
//stan != running przy żywej rozmowie wymaga próby wznowienia.
//Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony
//(armVoiceContextKickstart sam jest idempotentny)
audioContext.resume().catch(() => {}); audioContext.resume().catch(() => {});
armVoiceContextKickstart();
} }
// Safety net for the call-key handshake: if others are in the call but // Safety net for the call-key handshake: if others are in the call but
// we never received the root key (raced/lost signal), ask for it // we never received the root key (raced/lost signal), ask for it
@ -772,94 +773,37 @@ class TaskQueue {
let voiceJoinInFlight = false; let voiceJoinInFlight = false;
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM === //Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu - //problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach)
// wymaga testów na realnych urządzeniach przed ponownym włączeniem). let voiceErrorLogsLeft = 6;
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje. function voiceLogError(where, err) {
const ENABLE_VOICE_OPUS = false; if (voiceErrorLogsLeft > 0) {
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak voiceErrorLogsLeft--;
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak console.warn("Voice: error [" + where + "]", err);
// 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) { //Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik //może zablokować kontekst stworzony po async-owych krokach dołączania do
//unikalności IV bez zmian //rozmowy). Listenery są one-shot i zdejmują się nawzajem.
const iv = nextVoiceIv(); let voiceCtxKickstartHandler = null;
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes); function armVoiceContextKickstart() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!audioContext) return;
const payload = new Uint8Array(14 + cipher.byteLength); if (voiceCtxKickstartHandler) return; //już uzbrojone
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); voiceCtxKickstartHandler = () => {
currentVoiceWs.send(payload.buffer); audioContext.resume({}).then(() => {
} if (audioContext.state === 'running') {
console.warn("Voice: AudioContext resumed after user gesture");
function decodeVoiceOpus(audioId, targetNode, opusBuffer) { document.removeEventListener('click', voiceCtxKickstartHandler);
try { document.removeEventListener('touchend', voiceCtxKickstartHandler);
if (typeof window.AudioDecoder !== 'function') return; document.removeEventListener('keydown', voiceCtxKickstartHandler);
let dec = remoteOpusDecoders[audioId]; voiceCtxKickstartHandler = null;
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];
} }
}).catch(() => {});
};
document.addEventListener('click', voiceCtxKickstartHandler);
document.addEventListener('touchend', voiceCtxKickstartHandler);
document.addEventListener('keydown', voiceCtxKickstartHandler);
} }
async function initiateVoiceCall(roomId = null) { async function initiateVoiceCall(roomId = null) {
@ -1061,7 +1005,17 @@ async function startVoiceSession(serverUrl, ticket) {
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
} }
if (audioContext.state === 'suspended') await audioContext.resume(); if (audioContext.state !== 'running') {
try { await audioContext.resume(); } catch (e) {}
}
if (audioContext.state !== 'running') {
//Polityka autoplay / stan "interrupted": kontekst nie wstał
//samoaktualizacyjnie. Bez tego cisza w OBIE strony (mikrofon
//i głośnik żyją w tym samym kontekście), mimo że wskaźniki
//mówienia i screen share działają (nie potrzebują kontekstu).
console.warn("Voice: AudioContext not running after open (" + audioContext.state + ") - arming user-gesture kickstart");
armVoiceContextKickstart();
}
applyVoiceSpeakerSink(); applyVoiceSpeakerSink();
setupVoiceOutput(); setupVoiceOutput();
@ -1107,30 +1061,6 @@ async function startVoiceSession(serverUrl, ticket) {
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; 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 iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -1175,9 +1105,8 @@ async function startVoiceSession(serverUrl, ticket) {
//podklucz nadawcy - deterministyczny z (root, clientId) //podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId); const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4 || type === 6) { if (type === 0 || type === 4) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
const isOpus = (type === 6);
const audioId = isScreenAudio ? `${clientId}_screen` : clientId; const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
remoteAudio[audioId].lastUpdate = Date.now(); remoteAudio[audioId].lastUpdate = Date.now();
@ -1188,17 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) {
audioDecryptChains[audioId].enqueue(async () => { audioDecryptChains[audioId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const senderKey = await senderKeyPromise;
if (!senderKey) return; if (!senderKey) {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
if (isOpus) { return;
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
} else {
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
} }
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {} } catch (err) {
voiceLogError('media.decrypt[' + audioId + ']', err);
}
}); });
} else if (type === 1 || type === 2) { } else if (type === 1 || type === 2) {
const decoderId = `${clientId}-${type}`; const decoderId = `${clientId}-${type}`;
@ -1388,6 +1317,15 @@ async function handleCallUpdate(data) {
for (let u of state) { for (let u of state) {
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
} }
} else if (state.length > 0 && Date.now() - lastVoiceKeyRequestAt > 1500) {
//Jesteśmy w rozmowie BEZ klucza (np. dołączyliśmy do trwającej) -
//nie czekamy na tick watchdoga (do 4 s), prosimy natychmiast,
//inaczej pierwsze sekundy rozmowy są u nas i u nich ciszą
lastVoiceKeyRequestAt = Date.now();
console.warn("Voice: joined keyless - requesting call key now");
for (const u of state) {
if (u !== String(window.id)) sendVoiceSignalToUser(u, roomId, true);
}
} }
updateVoiceUIUsers(); updateVoiceUIUsers();
@ -1586,6 +1524,7 @@ async function handleRoomSignal(data) {
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem); let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes); let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
await setVoiceRootKey(base64ToUint8(decRoomKeyB64)); await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
} }
} catch(e) { } catch(e) {
console.error("Signal decryption failed", e); console.error("Signal decryption failed", e);
@ -2655,14 +2594,6 @@ function endVoiceSession() {
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false; 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) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}
} }