remove broken opus implementation
This commit is contained in:
parent
b2a6c19bc0
commit
6f83368b06
2 changed files with 134 additions and 272 deletions
|
|
@ -153,10 +153,6 @@ 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) {
|
||||
|
|
@ -647,8 +643,13 @@ setInterval(() => {
|
|||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||
console.warn("Voice: no inbound traffic, forcing reconnect");
|
||||
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(() => {});
|
||||
armVoiceContextKickstart();
|
||||
}
|
||||
// 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
|
||||
|
|
@ -772,94 +773,37 @@ 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;
|
||||
//Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego
|
||||
//problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach)
|
||||
let voiceErrorLogsLeft = 6;
|
||||
function voiceLogError(where, err) {
|
||||
if (voiceErrorLogsLeft > 0) {
|
||||
voiceErrorLogsLeft--;
|
||||
console.warn("Voice: error [" + where + "]", err);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
//Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay
|
||||
//może zablokować kontekst stworzony po async-owych krokach dołączania do
|
||||
//rozmowy). Listenery są one-shot i zdejmują się nawzajem.
|
||||
let voiceCtxKickstartHandler = null;
|
||||
function armVoiceContextKickstart() {
|
||||
if (!audioContext) return;
|
||||
if (voiceCtxKickstartHandler) return; //już uzbrojone
|
||||
voiceCtxKickstartHandler = () => {
|
||||
audioContext.resume({}).then(() => {
|
||||
if (audioContext.state === 'running') {
|
||||
console.warn("Voice: AudioContext resumed after user gesture");
|
||||
document.removeEventListener('click', voiceCtxKickstartHandler);
|
||||
document.removeEventListener('touchend', voiceCtxKickstartHandler);
|
||||
document.removeEventListener('keydown', voiceCtxKickstartHandler);
|
||||
voiceCtxKickstartHandler = null;
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
document.addEventListener('click', voiceCtxKickstartHandler);
|
||||
document.addEventListener('touchend', voiceCtxKickstartHandler);
|
||||
document.addEventListener('keydown', voiceCtxKickstartHandler);
|
||||
}
|
||||
|
||||
async function initiateVoiceCall(roomId = null) {
|
||||
|
|
@ -1061,7 +1005,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||
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();
|
||||
setupVoiceOutput();
|
||||
|
||||
|
|
@ -1107,30 +1061,6 @@ 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);
|
||||
|
|
@ -1175,9 +1105,8 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
//podklucz nadawcy - deterministyczny z (root, clientId)
|
||||
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
||||
|
||||
if (type === 0 || type === 4 || type === 6) {
|
||||
if (type === 0 || type === 4) {
|
||||
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();
|
||||
|
|
@ -1188,17 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
audioDecryptChains[audioId].enqueue(async () => {
|
||||
try {
|
||||
const senderKey = await senderKeyPromise;
|
||||
if (!senderKey) return;
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||
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]);
|
||||
if (!senderKey) {
|
||||
voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
voiceLogError('media.decrypt[' + audioId + ']', err);
|
||||
}
|
||||
});
|
||||
} else if (type === 1 || type === 2) {
|
||||
const decoderId = `${clientId}-${type}`;
|
||||
|
|
@ -1388,6 +1317,15 @@ async function handleCallUpdate(data) {
|
|||
for (let u of state) {
|
||||
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();
|
||||
|
||||
|
|
@ -1586,6 +1524,7 @@ async function handleRoomSignal(data) {
|
|||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
||||
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Signal decryption failed", e);
|
||||
|
|
@ -2654,14 +2593,6 @@ function endVoiceSession() {
|
|||
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){}
|
||||
|
|
|
|||
197
webroot/voice.js
197
webroot/voice.js
|
|
@ -153,10 +153,6 @@ 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) {
|
||||
|
|
@ -647,8 +643,13 @@ setInterval(() => {
|
|||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||
console.warn("Voice: no inbound traffic, forcing reconnect");
|
||||
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(() => {});
|
||||
armVoiceContextKickstart();
|
||||
}
|
||||
// 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
|
||||
|
|
@ -772,94 +773,37 @@ 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;
|
||||
//Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego
|
||||
//problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach)
|
||||
let voiceErrorLogsLeft = 6;
|
||||
function voiceLogError(where, err) {
|
||||
if (voiceErrorLogsLeft > 0) {
|
||||
voiceErrorLogsLeft--;
|
||||
console.warn("Voice: error [" + where + "]", err);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
//Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay
|
||||
//może zablokować kontekst stworzony po async-owych krokach dołączania do
|
||||
//rozmowy). Listenery są one-shot i zdejmują się nawzajem.
|
||||
let voiceCtxKickstartHandler = null;
|
||||
function armVoiceContextKickstart() {
|
||||
if (!audioContext) return;
|
||||
if (voiceCtxKickstartHandler) return; //już uzbrojone
|
||||
voiceCtxKickstartHandler = () => {
|
||||
audioContext.resume({}).then(() => {
|
||||
if (audioContext.state === 'running') {
|
||||
console.warn("Voice: AudioContext resumed after user gesture");
|
||||
document.removeEventListener('click', voiceCtxKickstartHandler);
|
||||
document.removeEventListener('touchend', voiceCtxKickstartHandler);
|
||||
document.removeEventListener('keydown', voiceCtxKickstartHandler);
|
||||
voiceCtxKickstartHandler = null;
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
document.addEventListener('click', voiceCtxKickstartHandler);
|
||||
document.addEventListener('touchend', voiceCtxKickstartHandler);
|
||||
document.addEventListener('keydown', voiceCtxKickstartHandler);
|
||||
}
|
||||
|
||||
async function initiateVoiceCall(roomId = null) {
|
||||
|
|
@ -1061,7 +1005,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||
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();
|
||||
setupVoiceOutput();
|
||||
|
||||
|
|
@ -1107,30 +1061,6 @@ 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);
|
||||
|
|
@ -1175,9 +1105,8 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
//podklucz nadawcy - deterministyczny z (root, clientId)
|
||||
const senderKeyPromise = getVoicePeerSenderKey(clientId);
|
||||
|
||||
if (type === 0 || type === 4 || type === 6) {
|
||||
if (type === 0 || type === 4) {
|
||||
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();
|
||||
|
|
@ -1188,17 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
audioDecryptChains[audioId].enqueue(async () => {
|
||||
try {
|
||||
const senderKey = await senderKeyPromise;
|
||||
if (!senderKey) return;
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||
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]);
|
||||
if (!senderKey) {
|
||||
voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
voiceLogError('media.decrypt[' + audioId + ']', err);
|
||||
}
|
||||
});
|
||||
} else if (type === 1 || type === 2) {
|
||||
const decoderId = `${clientId}-${type}`;
|
||||
|
|
@ -1388,6 +1317,15 @@ async function handleCallUpdate(data) {
|
|||
for (let u of state) {
|
||||
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();
|
||||
|
||||
|
|
@ -1586,6 +1524,7 @@ async function handleRoomSignal(data) {
|
|||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
||||
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
|
||||
}
|
||||
} catch(e) {
|
||||
console.error("Signal decryption failed", e);
|
||||
|
|
@ -2654,14 +2593,6 @@ function endVoiceSession() {
|
|||
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){}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue