feat(webrtc): end-to-end encrypted voice & video calls with adaptive codecs
Add 1:1 voice and video calling over the existing SAS-verified peer connection. Audio and video tracks ride the same RTCPeerConnection as the chat, bundled onto one DTLS-SRTP transport, so media inherits the session's end-to-end encryption. SDP offer/answer is renegotiated in-band over the verified data channel — no signalling server, so the media's DTLS fingerprints are authenticated end-to-end. Calls are gated on a connected, SAS-verified session. Codecs & adaptation: - Opus tuned for lossy links (in-band FEC, DTX, RED redundancy); audio is bandwidth-prioritised and never throttled. - VP9/AV1 single-encoding SVC with H.264/VP8 fallback; video degrades by spatial/temporal layer. - Runtime NetworkAdaptationController trims video bitrate on loss/RTT and recovers as the link clears — no renegotiation. Live connection-quality indicator (Excellent/Good/Fair/Weak) in the call UI. In-call controls: mute, camera on/off (voice→video upgrade in-band), camera flip, minimize-to-widget, hang up, and accept/decline for incoming calls. Production logging disabled (DEBUG_MODE=false); temporary call diagnostic logger removed. Codec rationale in docs/webrtc-config.md.
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
// Import EnhancedSecureFileTransfer
|
||||
import { EnhancedSecureFileTransfer } from '../transfer/EnhancedSecureFileTransfer.js';
|
||||
|
||||
// Adaptive voice/video call stack: codec tuning, SDP munging, network adaptation.
|
||||
import { AUDIO_CONFIG, TRANSPORT_CONFIG } from './webrtc/config.js';
|
||||
import { applyOpusSettings, applyTransport } from './webrtc/sdp.js';
|
||||
import { configureAudioSender, applyAudioCodecPreferences } from './webrtc/audio.js';
|
||||
import { configureVideoSender, applyVideoCodecPreferences } from './webrtc/video.js';
|
||||
import { NetworkAdaptationController } from './webrtc/adaptation/controller.js';
|
||||
|
||||
// MUTEX SYSTEM FIXES - RESOLVING MESSAGE DELIVERY ISSUES
|
||||
// ============================================
|
||||
// Issue: After introducing the Mutex system, messages stopped being delivered between users
|
||||
@@ -94,7 +101,17 @@ class EnhancedSecureWebRTCManager {
|
||||
CHUNK_CONFIRMATION: 'chunk_confirmation',
|
||||
FILE_TRANSFER_COMPLETE: 'file_transfer_complete',
|
||||
FILE_TRANSFER_ERROR: 'file_transfer_error',
|
||||
|
||||
|
||||
// Encrypted voice / video calls. SDP + ICE are exchanged over the
|
||||
// already-authenticated (ECDH + SAS-verified) data channel, so the
|
||||
// DTLS-SRTP fingerprints negotiated for the media are themselves
|
||||
// authenticated end-to-end — a signalling server never sees them.
|
||||
CALL_OFFER: 'call_offer',
|
||||
CALL_ANSWER: 'call_answer',
|
||||
CALL_ICE: 'call_ice',
|
||||
CALL_DECLINE: 'call_decline',
|
||||
CALL_END: 'call_end',
|
||||
|
||||
// Fake traffic
|
||||
FAKE: 'fake'
|
||||
};
|
||||
@@ -119,7 +136,7 @@ class EnhancedSecureWebRTCManager {
|
||||
]);
|
||||
|
||||
// Static debug flag instead of this._debugMode
|
||||
static DEBUG_MODE = true; // Set to true during development, false in production
|
||||
static DEBUG_MODE = false; // Set to true during development, false in production
|
||||
|
||||
|
||||
constructor(onMessage, onStatusChange, onKeyExchange, onVerificationRequired, onAnswerError = null, onVerificationStateChange = null, config = {}) {
|
||||
@@ -364,7 +381,34 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
};
|
||||
this.onFileReceived = null;
|
||||
this.onFileError = null;
|
||||
|
||||
|
||||
// ── Encrypted call subsystem ───────────────────────────────────────────
|
||||
// Media (audio/video) rides the SAME RTCPeerConnection as the data channel
|
||||
// (bundled over one DTLS-SRTP transport), so it inherits the connection's
|
||||
// end-to-end encryption and needs no new ICE. Signalling is renegotiated
|
||||
// in-band over the verified data channel. UI observes state via the
|
||||
// onCallStateChanged callback and the 'securebit-call-state' DOM event.
|
||||
this.onCallStateChanged = null; // (state) => void
|
||||
this.callState = {
|
||||
active: false, // a call session exists (ringing or connected)
|
||||
phase: 'idle', // idle | outgoing | incoming | connecting | active | ended
|
||||
withVideo: false, // whether video is part of this call
|
||||
micEnabled: true,
|
||||
cameraEnabled: false,
|
||||
remoteHasVideo: false,
|
||||
callId: null,
|
||||
quality: null, // 'excellent'|'good'|'fair'|'poor'|null — link quality for the UI
|
||||
error: null
|
||||
};
|
||||
this.localMediaStream = null;
|
||||
this.remoteMediaStream = null;
|
||||
this._pendingCallOffer = null; // { sdp, callId, withVideo } awaiting accept
|
||||
this._callMakingOffer = false; // perfect-negotiation guard
|
||||
this._callAudioSender = null;
|
||||
this._callVideoSender = null;
|
||||
this._callFacingMode = 'user';
|
||||
this._adaptationController = null; // NetworkAdaptationController while a call is active
|
||||
|
||||
// PFS (Perfect Forward Secrecy) Implementation
|
||||
this.keyRotationInterval = null; // отключаем таймерную ротацию
|
||||
this.lastKeyRotation = Date.now();
|
||||
@@ -6850,6 +6894,20 @@ async processMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encrypted call signalling (offer / answer / ice / decline / end).
|
||||
if (parsed.type && [
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END
|
||||
].includes(parsed.type)) {
|
||||
try { await this._handleCallSignal(parsed.type, parsed.data || {}); } catch (e) {
|
||||
this._secureLog('error', '❌ Call signal handling failed', { errorType: e?.constructor?.name });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYSTEM MESSAGES (WITHOUT MUTEX)
|
||||
// ============================================
|
||||
@@ -7669,6 +7727,11 @@ async processMessage(data) {
|
||||
|
||||
this.peerConnection = new RTCPeerConnection(config);
|
||||
|
||||
// A fresh PC invalidates any call senders kept from a previous PC — reset
|
||||
// them so the next call creates new transceivers on this connection.
|
||||
this._callAudioSender = null;
|
||||
this._callVideoSender = null;
|
||||
|
||||
this.peerConnection.onconnectionstatechange = () => {
|
||||
const state = this.peerConnection.connectionState;
|
||||
console.info('[SecureBit ICE] connection state changed', {
|
||||
@@ -7729,8 +7792,20 @@ async processMessage(data) {
|
||||
});
|
||||
};
|
||||
|
||||
// Inbound call media (DTLS-SRTP encrypted, bundled on this same transport).
|
||||
// ontrack does NOT re-fire for a REUSED transceiver on a later call, so we
|
||||
// never rely on the event's track alone — we rebuild the remote stream from
|
||||
// pc.getReceivers() (the source of truth for current live inbound tracks).
|
||||
this.peerConnection.ontrack = (event) => {
|
||||
try {
|
||||
this._refreshRemoteStream();
|
||||
} catch (e) {
|
||||
this._secureLog('warn', '⚠️ ontrack handling failed', { errorType: e?.constructor?.name });
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.ondatachannel = (event) => {
|
||||
|
||||
|
||||
// CRITICAL: Store the received data channel
|
||||
if (event.channel.label === 'securechat') {
|
||||
this.dataChannel = event.channel;
|
||||
@@ -7911,6 +7986,22 @@ async processMessage(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encrypted call signalling (offer / answer / ice / decline / end).
|
||||
// This is the live inbound path for the data channel — the call
|
||||
// types are NOT in the system-message list above, so they must be
|
||||
// routed explicitly here or they would be silently dropped.
|
||||
if (parsed.type && [
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ICE,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE,
|
||||
EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END
|
||||
].includes(parsed.type)) {
|
||||
try { await this._handleCallSignal(parsed.type, parsed.data || {}); }
|
||||
catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYSTEM MESSAGES (WITHOUT MUTEX)
|
||||
// ============================================
|
||||
@@ -12226,6 +12317,13 @@ async processMessage(data) {
|
||||
try {
|
||||
this._sessionAlive = false;
|
||||
|
||||
// End any active call and fully release the persistent mic/camera capture
|
||||
// (kept alive between calls; the session is ending now).
|
||||
try { this._stopAdaptation?.(); } catch (_) {}
|
||||
try { this._stopLocalMediaPermanently?.(); } catch (_) {}
|
||||
this._callAudioSender = null;
|
||||
this._callVideoSender = null;
|
||||
|
||||
// Preserve the explicit-disconnect notification flow before channels are closed.
|
||||
this.intentionalDisconnect = true;
|
||||
window.EnhancedSecureCryptoUtils.secureLog.log('info', 'Starting intentional disconnect');
|
||||
@@ -12841,6 +12939,505 @@ checkFileTransferReadiness() {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// ENCRYPTED VOICE / VIDEO CALLS
|
||||
//
|
||||
// Media is carried by the existing RTCPeerConnection: audio/video tracks
|
||||
// are bundled onto the same DTLS-SRTP transport already used by the data
|
||||
// channel, so they are end-to-end encrypted with the very connection that
|
||||
// in-person SAS verification authenticated. Renegotiation SDP travels over
|
||||
// the verified data channel (never a server), so the media's DTLS
|
||||
// fingerprints are authenticated end-to-end too. Calls are therefore only
|
||||
// permitted once the session is connected AND SAS-verified.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
getCallState() {
|
||||
return { ...this.callState };
|
||||
}
|
||||
|
||||
getRemoteMediaStream() {
|
||||
return this.remoteMediaStream;
|
||||
}
|
||||
|
||||
getLocalMediaStream() {
|
||||
return this.localMediaStream;
|
||||
}
|
||||
|
||||
// Rebuild the remote MediaStream from the PC's current receivers. This is the
|
||||
// reliable source of inbound tracks — ontrack does not re-fire for a reused
|
||||
// transceiver on a later call, so relying on it dropped remote audio/video.
|
||||
_refreshRemoteStream() {
|
||||
const pc = this.peerConnection;
|
||||
if (!pc || typeof pc.getReceivers !== 'function') return;
|
||||
const live = pc.getReceivers()
|
||||
.map(r => r.track)
|
||||
.filter(t => t && (t.kind === 'audio' || t.kind === 'video') && t.readyState === 'live');
|
||||
const prev = this.remoteMediaStream ? this.remoteMediaStream.getTracks() : [];
|
||||
const unchanged = prev.length === live.length && prev.every(t => live.includes(t));
|
||||
if (!unchanged) {
|
||||
// Build a NEW MediaStream so the UI's srcObject-changed check fires and
|
||||
// re-attaches (an element does not reliably pick up a track added to a
|
||||
// stream that is already its srcObject).
|
||||
this.remoteMediaStream = new MediaStream(live);
|
||||
}
|
||||
this._updateCallState({ remoteHasVideo: live.some(t => t.kind === 'video') });
|
||||
}
|
||||
|
||||
// Remote tracks can take a beat to go 'live' after setRemoteDescription, and
|
||||
// ontrack may not fire for reused transceivers — so refresh now and shortly
|
||||
// after to reliably pick up the inbound audio/video.
|
||||
_scheduleRemoteRefresh() {
|
||||
this._refreshRemoteStream();
|
||||
setTimeout(() => { try { this._refreshRemoteStream(); } catch (_) {} }, 300);
|
||||
setTimeout(() => { try { this._refreshRemoteStream(); } catch (_) {} }, 1200);
|
||||
}
|
||||
|
||||
_updateCallState(patch) {
|
||||
this.callState = { ...this.callState, ...patch };
|
||||
const snapshot = this.getCallState();
|
||||
// Adaptation controller follows the call lifecycle: run while active,
|
||||
// stop when the call ends.
|
||||
if (snapshot.phase === 'active') this._startAdaptation();
|
||||
else if (snapshot.phase === 'idle') this._stopAdaptation();
|
||||
try { this.onCallStateChanged?.(snapshot); } catch (_) {}
|
||||
if (typeof document !== 'undefined') {
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('securebit-call-state', {
|
||||
detail: { managerId: this._managerId || null, state: snapshot }
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
_callCanStart() {
|
||||
const connected = typeof this.isConnected === 'function' ? this.isConnected() : false;
|
||||
const channelOpen = this.dataChannel && this.dataChannel.readyState === 'open';
|
||||
// SAS verification is mandatory: it is what authenticates the DTLS
|
||||
// transport the media rides on, closing the MITM window.
|
||||
const ok = !!(connected && channelOpen && this.isVerified);
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendCallSignal(type, data) {
|
||||
const sent = await this.sendSystemMessage({ type, ...data });
|
||||
return sent;
|
||||
}
|
||||
|
||||
_audioConstraints() {
|
||||
return { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
||||
}
|
||||
_videoConstraints() {
|
||||
return { facingMode: this._callFacingMode, width: { ideal: 1280 }, height: { ideal: 720 } };
|
||||
}
|
||||
|
||||
async _acquireLocalMedia(withVideo) {
|
||||
// Fresh capture each call (the mic/camera is fully released on hang-up, so
|
||||
// the OS indicator goes off). Senders are reused across calls via
|
||||
// replaceTrack; addTrack only on first use.
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: this._audioConstraints(),
|
||||
video: withVideo ? this._videoConstraints() : false
|
||||
});
|
||||
this.localMediaStream = stream;
|
||||
const pc = this.peerConnection;
|
||||
const audioTrack = stream.getAudioTracks()[0] || null;
|
||||
const videoTrack = stream.getVideoTracks()[0] || null;
|
||||
if (audioTrack) {
|
||||
if (this._callAudioSender) await this._callAudioSender.replaceTrack(audioTrack);
|
||||
else this._callAudioSender = pc.addTrack(audioTrack, stream);
|
||||
}
|
||||
if (videoTrack) {
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = pc.addTrack(videoTrack, stream);
|
||||
}
|
||||
this._applyCallCodecPrefs(); // codec prefs BEFORE createOffer/createAnswer
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Fully release the mic/camera — only on session disconnect, not between calls.
|
||||
_stopLocalMediaPermanently() {
|
||||
try {
|
||||
if (this.localMediaStream) {
|
||||
for (const t of this.localMediaStream.getTracks()) { try { t.stop(); } catch (_) {} }
|
||||
}
|
||||
} catch (_) {}
|
||||
this.localMediaStream = null;
|
||||
}
|
||||
|
||||
// Codec preferences on the audio (RED→Opus) and video (VP9→AV1→H264→VP8)
|
||||
// transceivers created by addTrack. Called before offer/answer creation.
|
||||
_applyCallCodecPrefs() {
|
||||
try {
|
||||
const trs = this.peerConnection?.getTransceivers?.() || [];
|
||||
const audioTr = trs.find(t => t.sender && t.sender === this._callAudioSender);
|
||||
if (audioTr) applyAudioCodecPreferences(audioTr);
|
||||
const videoTr = trs.find(t => t.sender && t.sender === this._callVideoSender);
|
||||
if (videoTr) applyVideoCodecPreferences(videoTr);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Munge locally-created call SDP: Opus FEC/DTX/bitrate fmtp + transport
|
||||
// feedback (TWCC/NACK/PLI/FIR/REMB) & the TWCC header extension. Both peers
|
||||
// run this, so the negotiated result carries it.
|
||||
_mungeCallSdp(sdp) {
|
||||
try {
|
||||
let out = applyOpusSettings(sdp, AUDIO_CONFIG.opusFmtp);
|
||||
out = applyTransport(out, TRANSPORT_CONFIG);
|
||||
return out;
|
||||
} catch (e) { return sdp; }
|
||||
}
|
||||
|
||||
// setLocalDescription with progressive fallback so munging can never break a
|
||||
// call: try full munge → Opus-only munge → raw. (Some browsers reject added
|
||||
// rtcp-fb/extmap lines in a local description; the raw path always works.)
|
||||
async _setLocalMunged(desc) {
|
||||
const pc = this.peerConnection;
|
||||
try {
|
||||
await pc.setLocalDescription({ type: desc.type, sdp: this._mungeCallSdp(desc.sdp) });
|
||||
return;
|
||||
} catch (e) {
|
||||
try {
|
||||
await pc.setLocalDescription({ type: desc.type, sdp: applyOpusSettings(desc.sdp, AUDIO_CONFIG.opusFmtp) });
|
||||
return;
|
||||
} catch (e2) {
|
||||
await pc.setLocalDescription(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sender-level params after setLocalDescription, when senders have live
|
||||
// parameters: audio priority/bitrate, video SVC/bitrate/degradation.
|
||||
async _applyCallSenderParams() {
|
||||
try {
|
||||
if (this._callAudioSender) await configureAudioSender(this._callAudioSender, {});
|
||||
if (this._callVideoSender) await configureVideoSender(this._callVideoSender, {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Start the reactive bitrate controller for the active call. Idempotent. It
|
||||
// also feeds the connection-quality indicator shown in the call UI.
|
||||
_startAdaptation() {
|
||||
if (this._adaptationController || !this.peerConnection) return;
|
||||
try {
|
||||
this._adaptationController = new NetworkAdaptationController(this.peerConnection, {
|
||||
getVideoSender: () => this._callVideoSender,
|
||||
ceilingBitrate: 1500000,
|
||||
onQuality: (q) => {
|
||||
if (q !== this.callState.quality) this._updateCallState({ quality: q });
|
||||
},
|
||||
});
|
||||
this._adaptationController.start();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_stopAdaptation() {
|
||||
if (this._adaptationController) {
|
||||
try { this._adaptationController.stop(); } catch (_) {}
|
||||
this._adaptationController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Turn a getUserMedia failure into a clear, user-visible reason (shown in the
|
||||
// chat as a system message) + a machine-readable callState.error code. These
|
||||
// are device/permission problems, NOT connection problems — surfacing them
|
||||
// stops the call from looking like it "silently drops".
|
||||
_notifyCallMediaError(error, wantVideo) {
|
||||
const name = error?.name || '';
|
||||
const dev = wantVideo ? 'camera/microphone' : 'microphone';
|
||||
let msg, code;
|
||||
if (name === 'NotAllowedError') {
|
||||
code = 'permission_denied';
|
||||
msg = `⚠️ Call not started — ${dev} access is blocked. Allow it for this site in the browser, and enable your browser under System Settings → Privacy & Security → ${wantVideo ? 'Camera/Microphone' : 'Microphone'}, then try again.`;
|
||||
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||||
code = 'device_not_found';
|
||||
msg = `⚠️ Call not started — no ${dev} found on this device.`;
|
||||
} else if (name === 'NotReadableError' || name === 'AbortError') {
|
||||
code = 'device_busy';
|
||||
msg = `⚠️ Call not started — your ${dev} is in use by another app. Close it and try again.`;
|
||||
} else {
|
||||
code = 'media_failed';
|
||||
msg = `⚠️ Call not started — could not access ${dev}${name ? ' (' + name + ')' : ''}.`;
|
||||
}
|
||||
try { this.deliverMessageToUI(msg, 'system'); } catch (_) {}
|
||||
return code;
|
||||
}
|
||||
|
||||
// Caller side: begin an outgoing call.
|
||||
async startCall(withVideo = false) {
|
||||
if (!this._callCanStart()) {
|
||||
this._updateCallState({ error: 'not_verified' });
|
||||
throw new Error('Calls require a connected, SAS-verified session.');
|
||||
}
|
||||
if (this.callState.active) {
|
||||
this._secureLog('warn', '⚠️ startCall ignored — a call is already active');
|
||||
return;
|
||||
}
|
||||
const callId = (crypto?.randomUUID?.() || String(Date.now()) + Math.random().toString(36).slice(2));
|
||||
this._updateCallState({
|
||||
active: true, phase: 'outgoing', withVideo, callId,
|
||||
micEnabled: true, cameraEnabled: withVideo, remoteHasVideo: false, error: null
|
||||
});
|
||||
try {
|
||||
await this._acquireLocalMedia(withVideo);
|
||||
this._callMakingOffer = true;
|
||||
const offer = await this.peerConnection.createOffer();
|
||||
await this._setLocalMunged(offer);
|
||||
this._callMakingOffer = false;
|
||||
await this._applyCallSenderParams();
|
||||
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, {
|
||||
callId, withVideo, sdp: this.peerConnection.localDescription.sdp
|
||||
});
|
||||
} catch (error) {
|
||||
this._callMakingOffer = false;
|
||||
this._secureLog('error', '❌ startCall failed', { errorType: error?.constructor?.name });
|
||||
const code = this._notifyCallMediaError(error, withVideo);
|
||||
await this._teardownCallMedia();
|
||||
this._updateCallState({ active: false, phase: 'idle', error: code });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Callee side: an inbound call offer arrived → surface it to the UI.
|
||||
async _onIncomingCallOffer(data) {
|
||||
// A renegotiation of an ALREADY-active call (e.g. audio → video upgrade,
|
||||
// or the peer turning their camera on) is auto-accepted transparently.
|
||||
if (this.callState.active && (this.callState.phase === 'active' || this.callState.phase === 'connecting')) {
|
||||
await this._answerCallOffer(data, /* renegotiation */ true);
|
||||
if (data.withVideo) this._updateCallState({ withVideo: true });
|
||||
return;
|
||||
}
|
||||
this._pendingCallOffer = data;
|
||||
this._updateCallState({
|
||||
active: true, phase: 'incoming', withVideo: !!data.withVideo,
|
||||
callId: data.callId, remoteHasVideo: !!data.withVideo, error: null
|
||||
});
|
||||
}
|
||||
|
||||
async _answerCallOffer(data, renegotiation = false) {
|
||||
await this.peerConnection.setRemoteDescription({ type: 'offer', sdp: data.sdp });
|
||||
// Attach OUR media on every fresh accept. Crucial: _acquireLocalMedia must
|
||||
// run even when a persistent localMediaStream already exists — teardown
|
||||
// detached the tracks from the senders (replaceTrack(null)), and this
|
||||
// re-attaches them. Without it the answerer sends silence/black (the "no
|
||||
// sound when I pick up / on the reverse call" bug). It reuses the live
|
||||
// capture internally, so no extra getUserMedia. Skip only on renegotiation
|
||||
// (upgrade to video), where the senders already carry live tracks.
|
||||
if (!renegotiation) {
|
||||
await this._acquireLocalMedia(!!data.withVideo);
|
||||
}
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
await this._setLocalMunged(answer);
|
||||
await this._applyCallSenderParams();
|
||||
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_ANSWER, {
|
||||
callId: data.callId, sdp: this.peerConnection.localDescription.sdp
|
||||
});
|
||||
}
|
||||
|
||||
// Callee accepts the ringing call.
|
||||
async acceptCall() {
|
||||
const data = this._pendingCallOffer;
|
||||
if (!data) return;
|
||||
this._pendingCallOffer = null;
|
||||
this._updateCallState({ phase: 'connecting', cameraEnabled: !!data.withVideo });
|
||||
try {
|
||||
await this._answerCallOffer(data, false);
|
||||
this._updateCallState({ phase: 'active' });
|
||||
this._scheduleRemoteRefresh();
|
||||
} catch (error) {
|
||||
this._secureLog('error', '❌ acceptCall failed', { errorType: error?.constructor?.name });
|
||||
const code = this._notifyCallMediaError(error, !!data.withVideo);
|
||||
// Tell the caller we couldn't pick up so their ringing UI stops.
|
||||
try { await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END, { callId: data.callId }); } catch (_) {}
|
||||
await this._teardownCallMedia();
|
||||
this._updateCallState({ active: false, phase: 'idle', error: code });
|
||||
}
|
||||
}
|
||||
|
||||
// Callee rejects the ringing call.
|
||||
async declineCall() {
|
||||
const callId = this.callState.callId;
|
||||
this._pendingCallOffer = null;
|
||||
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_DECLINE, { callId });
|
||||
this._updateCallState({ active: false, phase: 'idle', withVideo: false, remoteHasVideo: false });
|
||||
}
|
||||
|
||||
// Either side hangs up.
|
||||
async endCall(sendSignal = true) {
|
||||
const callId = this.callState.callId;
|
||||
if (sendSignal && callId) {
|
||||
try { await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_END, { callId }); } catch (_) {}
|
||||
}
|
||||
await this._teardownCallMedia();
|
||||
this._updateCallState({
|
||||
active: false, phase: 'idle', withVideo: false, micEnabled: true,
|
||||
cameraEnabled: false, remoteHasVideo: false, callId: null, quality: null
|
||||
});
|
||||
}
|
||||
|
||||
async _teardownCallMedia() {
|
||||
this._stopAdaptation();
|
||||
const pc = this.peerConnection;
|
||||
try {
|
||||
// 1. STOP our capture tracks → releases the mic/camera so the OS
|
||||
// indicator goes off after the call.
|
||||
if (this.localMediaStream) {
|
||||
for (const track of this.localMediaStream.getTracks()) { try { track.stop(); } catch (_) {} }
|
||||
}
|
||||
// 2. Do NOT stop the REMOTE receiver tracks: the transceivers are reused
|
||||
// on the next call and ontrack won't re-fire, so a stopped receiver
|
||||
// track would leave the next call with NO remote audio/video. They go
|
||||
// 'muted' when the peer stops sending (idle, negligible CPU) and are
|
||||
// fully released in disconnect(). Just detach our senders.
|
||||
if (pc) {
|
||||
for (const sender of [this._callAudioSender, this._callVideoSender].filter(Boolean)) {
|
||||
try { await sender.replaceTrack(null); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
this.localMediaStream = null;
|
||||
this.remoteMediaStream = null;
|
||||
// _callAudioSender/_callVideoSender are KEPT (reused next call; reset only
|
||||
// when the PC is rebuilt — see createPeerConnection).
|
||||
this._callMakingOffer = false;
|
||||
// If we created an offer that was never answered (call declined / failed
|
||||
// mid-negotiation), the PC is stuck in 'have-local-offer'. Roll it back to
|
||||
// 'stable' so the data channel and future calls keep working.
|
||||
try {
|
||||
if (this.peerConnection &&
|
||||
(this.peerConnection.signalingState === 'have-local-offer' ||
|
||||
this.peerConnection.signalingState === 'have-local-pranswer')) {
|
||||
await this.peerConnection.setLocalDescription({ type: 'rollback' });
|
||||
}
|
||||
} catch (_) {}
|
||||
// No teardown renegotiation otherwise: stopping the tracks fires
|
||||
// 'ended'/'mute' on the peer's receivers, and the next startCall
|
||||
// renegotiates the PC from scratch anyway. Skipping it avoids offer/answer
|
||||
// glare when both ends hang up at once; dormant transceivers are reused.
|
||||
}
|
||||
|
||||
// Mute / unmute the microphone (no renegotiation — just toggles the track).
|
||||
setMicEnabled(enabled) {
|
||||
if (this.localMediaStream) {
|
||||
this.localMediaStream.getAudioTracks().forEach(t => { t.enabled = enabled; });
|
||||
}
|
||||
this._updateCallState({ micEnabled: enabled });
|
||||
}
|
||||
|
||||
toggleMic() { this.setMicEnabled(!this.callState.micEnabled); }
|
||||
|
||||
// Turn the camera on/off. Turning it on for an audio-only call adds a video
|
||||
// track and renegotiates (an in-call "upgrade to video").
|
||||
async setCameraEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
if (this.localMediaStream) {
|
||||
this.localMediaStream.getVideoTracks().forEach(t => { t.enabled = false; });
|
||||
}
|
||||
this._updateCallState({ cameraEnabled: false });
|
||||
return;
|
||||
}
|
||||
// Enabling: reuse an existing (disabled) track, otherwise add one.
|
||||
const existing = this.localMediaStream?.getVideoTracks?.() || [];
|
||||
if (existing.length) {
|
||||
existing.forEach(t => { t.enabled = true; });
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
return;
|
||||
}
|
||||
await this.upgradeToVideo();
|
||||
}
|
||||
|
||||
async toggleCamera() { await this.setCameraEnabled(!this.callState.cameraEnabled); }
|
||||
|
||||
// Add a camera to an in-progress audio call and renegotiate.
|
||||
async upgradeToVideo() {
|
||||
if (!this.localMediaStream) return;
|
||||
try {
|
||||
// Add ONLY a camera track to the in-call stream (don't re-capture audio).
|
||||
const camStream = await navigator.mediaDevices.getUserMedia({ video: this._videoConstraints() });
|
||||
const videoTrack = camStream.getVideoTracks()[0];
|
||||
if (!videoTrack) return;
|
||||
this.localMediaStream.addTrack(videoTrack);
|
||||
if (this._callVideoSender) await this._callVideoSender.replaceTrack(videoTrack);
|
||||
else this._callVideoSender = this.peerConnection.addTrack(videoTrack, this.localMediaStream);
|
||||
this._applyCallCodecPrefs();
|
||||
this._updateCallState({ cameraEnabled: true, withVideo: true });
|
||||
await this._renegotiateCall();
|
||||
} catch (error) {
|
||||
this._secureLog('error', '❌ upgradeToVideo failed', { errorType: error?.constructor?.name });
|
||||
this._updateCallState({ cameraEnabled: false, error: 'camera_failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Flip between front/back cameras without renegotiation (replaceTrack).
|
||||
async switchCamera() {
|
||||
if (!this._callVideoSender || !this.localMediaStream) return;
|
||||
this._callFacingMode = this._callFacingMode === 'user' ? 'environment' : 'user';
|
||||
try {
|
||||
const camStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: this._callFacingMode }
|
||||
});
|
||||
const newTrack = camStream.getVideoTracks()[0];
|
||||
const old = this.localMediaStream.getVideoTracks()[0];
|
||||
if (old) { this.localMediaStream.removeTrack(old); try { old.stop(); } catch (_) {} }
|
||||
this.localMediaStream.addTrack(newTrack);
|
||||
await this._callVideoSender.replaceTrack(newTrack);
|
||||
} catch (error) {
|
||||
this._secureLog('warn', '⚠️ switchCamera failed', { errorType: error?.constructor?.name });
|
||||
}
|
||||
}
|
||||
|
||||
async _renegotiateCall() {
|
||||
if (this._callMakingOffer) return;
|
||||
try {
|
||||
this._callMakingOffer = true;
|
||||
const offer = await this.peerConnection.createOffer();
|
||||
await this._setLocalMunged(offer);
|
||||
await this._applyCallSenderParams();
|
||||
await this._sendCallSignal(EnhancedSecureWebRTCManager.MESSAGE_TYPES.CALL_OFFER, {
|
||||
callId: this.callState.callId, withVideo: this.callState.withVideo,
|
||||
sdp: this.peerConnection.localDescription.sdp
|
||||
});
|
||||
} finally {
|
||||
this._callMakingOffer = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Central inbound call-signal router (called from processMessage).
|
||||
async _handleCallSignal(type, data) {
|
||||
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
||||
switch (type) {
|
||||
case T.CALL_OFFER: {
|
||||
await this._onIncomingCallOffer(data);
|
||||
return;
|
||||
}
|
||||
case T.CALL_ANSWER: {
|
||||
try {
|
||||
if (this.peerConnection.signalingState === 'have-local-offer') {
|
||||
await this.peerConnection.setRemoteDescription({ type: 'answer', sdp: data.sdp });
|
||||
}
|
||||
if (this.callState.phase === 'outgoing') this._updateCallState({ phase: 'active' });
|
||||
this._scheduleRemoteRefresh(); // caller: pick up the answerer's tracks
|
||||
} catch (e) {
|
||||
this._secureLog('warn', '⚠️ Failed to apply call answer', { errorType: e?.constructor?.name });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case T.CALL_ICE: {
|
||||
try { if (data.candidate) await this.peerConnection.addIceCandidate(data.candidate); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
case T.CALL_DECLINE: {
|
||||
await this._teardownCallMedia();
|
||||
this._updateCallState({ active: false, phase: 'idle', withVideo: false, remoteHasVideo: false, error: 'declined' });
|
||||
return;
|
||||
}
|
||||
case T.CALL_END: {
|
||||
await this.endCall(/* sendSignal */ false);
|
||||
return;
|
||||
}
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecureKeyStorage {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// NetworkAdaptationController — reactive bitrate control for calls.
|
||||
//
|
||||
// Once a second it reads pc.getStats(), classifies the link, and nudges the VIDEO
|
||||
// sender's maxBitrate / scaleResolutionDownBy via setParameters — never
|
||||
// renegotiation, never touching the tracks, never touching AUDIO (audio stays at
|
||||
// its configured bitrate so speech survives; see ADAPTATION_CONFIG.audioProtect).
|
||||
//
|
||||
// The decision logic (decideAdaptation) is pure and unit-tested separately from
|
||||
// the live getStats/setParameters plumbing.
|
||||
|
||||
import { ADAPTATION_CONFIG, IS_WEBKIT } from '../config.js';
|
||||
import { summarizeStats, qualityFromMetrics } from './metrics.js';
|
||||
|
||||
/**
|
||||
* Pure adaptation decision.
|
||||
* @param {object} m metrics from summarizeStats
|
||||
* @param {object} state { targetBitrate, ceilingBitrate, scaleResolutionDownBy, goodTicks }
|
||||
* @param {object} cfg ADAPTATION_CONFIG
|
||||
* @returns {{targetBitrate:number, scaleResolutionDownBy:number, goodTicks:number,
|
||||
* changed:boolean, reason:string}}
|
||||
*/
|
||||
export function decideAdaptation(m, state, cfg = ADAPTATION_CONFIG) {
|
||||
let { targetBitrate, ceilingBitrate, scaleResolutionDownBy, goodTicks } = state;
|
||||
let changed = false, reason = 'steady';
|
||||
|
||||
if (m.qualityLimitationReason === 'cpu') {
|
||||
// CPU-bound: drop resolution, leave bitrate alone (brief §5).
|
||||
const next = Math.min(4, +(scaleResolutionDownBy * cfg.cpuScaleStep).toFixed(3));
|
||||
if (next !== scaleResolutionDownBy) { scaleResolutionDownBy = next; changed = true; }
|
||||
goodTicks = 0; reason = 'cpu';
|
||||
} else if (m.lossPct > cfg.loss.highPct || m.rttMs > cfg.rtt.highMs) {
|
||||
// Congestion: step video bitrate down (never below the floor).
|
||||
const next = Math.max(cfg.minVideoBitrate, Math.round(targetBitrate * (1 - cfg.stepDownPct)));
|
||||
if (next !== targetBitrate) { targetBitrate = next; changed = true; }
|
||||
goodTicks = 0; reason = 'backoff';
|
||||
} else if (m.lossPct < cfg.loss.recoverPct && m.rttMs < cfg.rtt.recoverMs) {
|
||||
// Sustained good link: ramp up after enough consecutive good ticks.
|
||||
goodTicks += 1; reason = 'recovering';
|
||||
if (goodTicks >= cfg.recoverStableTicks) {
|
||||
const next = Math.min(ceilingBitrate, Math.round(targetBitrate * (1 + cfg.stepUpPct)));
|
||||
if (next !== targetBitrate) { targetBitrate = next; changed = true; reason = 'rampup'; }
|
||||
goodTicks = 0;
|
||||
}
|
||||
} else {
|
||||
goodTicks = 0; // neutral zone: hold.
|
||||
}
|
||||
|
||||
return { targetBitrate, scaleResolutionDownBy, goodTicks, changed, reason };
|
||||
}
|
||||
|
||||
export class NetworkAdaptationController {
|
||||
/**
|
||||
* @param {RTCPeerConnection} pc
|
||||
* @param {object} opts { getVideoSender:()=>RTCRtpSender|null, ceilingBitrate?, onQuality?, cfg? }
|
||||
*/
|
||||
constructor(pc, opts = {}) {
|
||||
this.pc = pc;
|
||||
this.getVideoSender = opts.getVideoSender || (() => null);
|
||||
this.onQuality = opts.onQuality || (() => {});
|
||||
this.cfg = opts.cfg || ADAPTATION_CONFIG;
|
||||
this._timer = null;
|
||||
this._prevCounters = {};
|
||||
this._lastQuality = undefined;
|
||||
this.state = {
|
||||
targetBitrate: opts.ceilingBitrate || 1500000,
|
||||
ceilingBitrate: opts.ceilingBitrate || 1500000,
|
||||
scaleResolutionDownBy: 1,
|
||||
goodTicks: 0,
|
||||
};
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._timer) return;
|
||||
this._timer = setInterval(() => { this._tick().catch(() => {}); }, this.cfg.intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
}
|
||||
|
||||
async _tick() {
|
||||
if (!this.pc || typeof this.pc.getStats !== 'function') return;
|
||||
const report = await this.pc.getStats();
|
||||
const stats = typeof report.values === 'function' ? Array.from(report.values()) : report;
|
||||
const m = summarizeStats(stats, this._prevCounters);
|
||||
this._prevCounters = m.counters;
|
||||
|
||||
// Quality → UI (only emit on change).
|
||||
const q = qualityFromMetrics(m);
|
||||
if (q && q !== this._lastQuality) {
|
||||
this._lastQuality = q;
|
||||
try { this.onQuality(q, m); } catch (_) {}
|
||||
}
|
||||
|
||||
if (!m.hasData) return;
|
||||
|
||||
const decision = decideAdaptation(m, this.state, this.cfg);
|
||||
this.state = {
|
||||
targetBitrate: decision.targetBitrate,
|
||||
ceilingBitrate: this.state.ceilingBitrate,
|
||||
scaleResolutionDownBy: decision.scaleResolutionDownBy,
|
||||
goodTicks: decision.goodTicks,
|
||||
};
|
||||
if (decision.changed) {
|
||||
await this._applyToVideoSender();
|
||||
}
|
||||
}
|
||||
|
||||
async _applyToVideoSender() {
|
||||
// WebKit: never write sender params (kills the capture); quality indicator
|
||||
// still works (read-only getStats). Safari's own congestion control adapts.
|
||||
if (IS_WEBKIT) return;
|
||||
const sender = this.getVideoSender();
|
||||
if (!sender || typeof sender.getParameters !== 'function') return;
|
||||
try {
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
|
||||
if (params.encodings.length === 1) {
|
||||
// Single encoding (SVC or plain): cap bitrate + scale resolution.
|
||||
params.encodings[0].maxBitrate = this.state.targetBitrate;
|
||||
params.encodings[0].scaleResolutionDownBy = this.state.scaleResolutionDownBy;
|
||||
} else {
|
||||
// Simulcast: throttle only the TOP (highest-bitrate) layer so the
|
||||
// low/mid layers keep flowing; leave their per-rid scale ladder intact.
|
||||
const top = params.encodings[params.encodings.length - 1];
|
||||
top.maxBitrate = this.state.targetBitrate;
|
||||
}
|
||||
await sender.setParameters(params);
|
||||
} catch (e) {
|
||||
// WebKit/older browsers can reject setParameters; safe to skip this tick.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// getStats parsing + quality classification for adaptive calls.
|
||||
//
|
||||
// summarizeStats() takes a plain array of RTCStats objects (Array.from(report
|
||||
// .values())) plus the previous counter snapshot, so it is fully unit-testable
|
||||
// in Node without a live RTCPeerConnection.
|
||||
|
||||
/**
|
||||
* Reduce a getStats() report to the few numbers the controller / UI need.
|
||||
* @param {Array<object>} stats RTCStats objects (video preferred over audio).
|
||||
* @param {{packetsSent?:number, packetsLost?:number}} [prev] previous counters.
|
||||
* @returns {{lossPct:number, rttMs:number, jitterMs:number,
|
||||
* availableOutgoingBitrate:(number|null), qualityLimitationReason:string,
|
||||
* counters:{packetsSent:number, packetsLost:number}, hasData:boolean}}
|
||||
*/
|
||||
export function summarizeStats(stats, prev = {}) {
|
||||
let outbound = null, remoteInbound = null, candidatePair = null;
|
||||
for (const s of stats) {
|
||||
if (!s || typeof s.type !== 'string') continue;
|
||||
if (s.type === 'outbound-rtp' && !s.isRemote) {
|
||||
if (!outbound || s.kind === 'video') outbound = s; // prefer video
|
||||
} else if (s.type === 'remote-inbound-rtp') {
|
||||
if (!remoteInbound || s.kind === 'video') remoteInbound = s;
|
||||
} else if (s.type === 'candidate-pair') {
|
||||
const active = s.nominated || s.selected || s.state === 'succeeded';
|
||||
if (active && (!candidatePair || s.nominated)) candidatePair = s;
|
||||
}
|
||||
}
|
||||
|
||||
const packetsSent = Number(outbound?.packetsSent ?? 0);
|
||||
const packetsLost = Number(remoteInbound?.packetsLost ?? 0);
|
||||
const dSent = packetsSent - (prev.packetsSent ?? packetsSent);
|
||||
const dLost = packetsLost - (prev.packetsLost ?? packetsLost);
|
||||
const denom = dSent + dLost;
|
||||
// Interval loss ratio (guard against counter resets / first sample).
|
||||
const lossPct = denom > 0 ? Math.min(1, Math.max(0, dLost / denom)) : 0;
|
||||
|
||||
const rttSec = candidatePair?.currentRoundTripTime ?? remoteInbound?.roundTripTime ?? 0;
|
||||
const rttMs = Number(rttSec) * 1000;
|
||||
const jitterMs = Number(remoteInbound?.jitter ?? 0) * 1000;
|
||||
const availableOutgoingBitrate = candidatePair?.availableOutgoingBitrate != null
|
||||
? Number(candidatePair.availableOutgoingBitrate) : null;
|
||||
const qualityLimitationReason = outbound?.qualityLimitationReason ?? 'none';
|
||||
|
||||
return {
|
||||
lossPct, rttMs, jitterMs, availableOutgoingBitrate, qualityLimitationReason,
|
||||
counters: { packetsSent, packetsLost },
|
||||
hasData: !!(outbound && (remoteInbound || candidatePair)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse connection-quality label for the UI, from loss + RTT.
|
||||
* @returns {'excellent'|'good'|'fair'|'poor'|null} null when there's no data yet.
|
||||
*/
|
||||
export function qualityFromMetrics(m) {
|
||||
if (!m || !m.hasData) return null;
|
||||
const l = m.lossPct, r = m.rttMs;
|
||||
if (l < 0.03 && r < 150) return 'excellent';
|
||||
if (l < 0.07 && r < 250) return 'good';
|
||||
if (l < 0.15 && r < 400) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Audio sender / transceiver configuration for calls.
|
||||
//
|
||||
// Splits the brief's "configureAudioSender" into the three WebRTC surfaces it
|
||||
// actually touches, because a single RTCRtpSender cannot do all of them:
|
||||
// - setCodecPreferences → on the TRANSCEIVER, before createOffer (RED ordering)
|
||||
// - fmtp munging → on the SDP string (see sdp.js applyOpusSettings)
|
||||
// - setParameters → on the SENDER, any time after it exists
|
||||
//
|
||||
// All functions feature-detect and swallow unsupported-API errors so Safari /
|
||||
// Firefox (which vary on RED and setCodecPreferences) degrade gracefully.
|
||||
|
||||
import { AUDIO_CONFIG, IS_WEBKIT } from './config.js';
|
||||
|
||||
/**
|
||||
* Order codecs so RED (RFC 2198) is preferred first and Opus second, keeping any
|
||||
* other codecs (e.g. telephone-event) after them. No-op when RED isn't offered
|
||||
* or setCodecPreferences is unsupported.
|
||||
* @param {RTCRtpTransceiver} transceiver
|
||||
*/
|
||||
export function applyAudioCodecPreferences(transceiver) {
|
||||
try {
|
||||
if (IS_WEBKIT) return false; // codec preferences skipped on WebKit (capture-safe)
|
||||
if (!transceiver || typeof transceiver.setCodecPreferences !== 'function') return false;
|
||||
if (!AUDIO_CONFIG.preferRed) return false;
|
||||
const caps = (typeof RTCRtpSender !== 'undefined' && RTCRtpSender.getCapabilities)
|
||||
? RTCRtpSender.getCapabilities('audio')
|
||||
: null;
|
||||
if (!caps || !Array.isArray(caps.codecs)) return false;
|
||||
|
||||
const isRed = c => /red$/i.test(c.mimeType);
|
||||
const isOpus = c => /opus$/i.test(c.mimeType);
|
||||
if (!caps.codecs.some(isRed)) return false; // RED not available
|
||||
|
||||
const red = caps.codecs.filter(isRed);
|
||||
const opus = caps.codecs.filter(isOpus);
|
||||
const rest = caps.codecs.filter(c => !isRed(c) && !isOpus(c));
|
||||
transceiver.setCodecPreferences([...red, ...opus, ...rest]);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sender-level parameters: high bandwidth priority, high network priority
|
||||
* (audio ahead of video on the wire) and a maxBitrate cap. Uses read-modify-write
|
||||
* on getParameters()/setParameters() so we never clobber existing encodings.
|
||||
* @param {RTCRtpSender} sender
|
||||
* @param {object} [options] overrides for AUDIO_CONFIG.sender
|
||||
*/
|
||||
export async function configureAudioSender(sender, options = {}) {
|
||||
try {
|
||||
if (IS_WEBKIT) return false; // sender setParameters skipped on WebKit (capture-safe)
|
||||
if (!sender || typeof sender.getParameters !== 'function') return false;
|
||||
const cfg = { ...AUDIO_CONFIG.sender, ...options };
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
|
||||
for (const enc of params.encodings) {
|
||||
enc.maxBitrate = cfg.maxBitrate;
|
||||
enc.priority = cfg.priority;
|
||||
enc.networkPriority = cfg.networkPriority;
|
||||
}
|
||||
await sender.setParameters(params);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Adaptive WebRTC call parameters — single source of truth.
|
||||
//
|
||||
// Every value here is a tuning constant for the voice/video call stack, with a
|
||||
// note on where it comes from. Kept as plain data (no behaviour) so it can be
|
||||
// imported by both the browser bundle and the Node unit tests.
|
||||
//
|
||||
// Targets (from the task brief): usable voice at up to 15–20% loss, RTT up to
|
||||
// 400 ms, fluctuating bandwidth; voice must not break as bitrate drops; video
|
||||
// degrades gracefully by layer.
|
||||
|
||||
// WebKit (desktop Safari + ALL iOS browsers) kills a live capture MediaStreamTrack
|
||||
// with "capture failure" when we manipulate the sender via setParameters /
|
||||
// setCodecPreferences (RED, priorities, SVC bitrate). So the adaptive codec/bitrate
|
||||
// tuning is applied on Chromium only; WebKit runs plain WebRTC (Opus + the
|
||||
// browser's own congestion control), which is reliable. The read-only quality
|
||||
// indicator still works everywhere. Matches Chrome/Chromium/Edge; excludes Safari
|
||||
// and iOS browsers (all WebKit — iOS Chrome is "CriOS", not "Chrome").
|
||||
export const IS_WEBKIT = (typeof navigator !== 'undefined')
|
||||
&& /AppleWebKit/.test(navigator.userAgent)
|
||||
&& !/Chrome|Chromium|Edg\//.test(navigator.userAgent);
|
||||
|
||||
// ── AUDIO ──────────────────────────────────────────────────────────────────
|
||||
// Opus is mandatory-to-implement in WebRTC (RFC 7874). FEC + DTX + a low target
|
||||
// bitrate keep speech intelligible under loss.
|
||||
export const AUDIO_CONFIG = {
|
||||
// Opus fmtp params applied via SDP munging.
|
||||
// - minptime=10 smaller packets → lower latency (Opus RFC 7587 §7).
|
||||
// - useinbandfec=1 in-band Forward Error Correction — reconstructs lost
|
||||
// packets from the next one (RFC 6716 §2.1.7). Key for loss.
|
||||
// - usedtx=1 Discontinuous Transmission — stop sending in silence,
|
||||
// frees the pipe for video/FEC (RFC 7587 §3.1.3).
|
||||
// - stereo=0 mono: voice doesn't need stereo, halves the bitrate.
|
||||
// - maxaveragebitrate 32 kbps — brief says 32000; comfortable wideband speech.
|
||||
// - cbr=0 variable bitrate: lets the encoder spend bits only when
|
||||
// needed, better quality per bit than CBR for speech.
|
||||
opusFmtp: {
|
||||
minptime: 10,
|
||||
useinbandfec: 1,
|
||||
usedtx: 1,
|
||||
stereo: 0,
|
||||
maxaveragebitrate: 32000,
|
||||
cbr: 0,
|
||||
},
|
||||
// RED (RFC 2198) wraps Opus payloads with a redundant copy of the previous
|
||||
// frame — recovers isolated losses without waiting for retransmission. Only
|
||||
// enabled when the browser advertises audio/red (Chromium yes; Safari/FF vary).
|
||||
preferRed: true,
|
||||
// RTCRtpSender.setParameters — encoding-level knobs. Audio is prioritised over
|
||||
// video on the shared transport so speech survives congestion.
|
||||
sender: {
|
||||
maxBitrate: 40000, // bps — brief: 40000. Head-room over 32 kbps for RED.
|
||||
priority: 'high', // RTCPriorityType — bandwidth arbitration within the PC.
|
||||
networkPriority: 'high', // DSCP marking hint — audio ahead of video on the wire.
|
||||
},
|
||||
};
|
||||
|
||||
// ── VIDEO ──────────────────────────────────────────────────────────────────
|
||||
// Preference order VP9 → AV1 → H.264 → VP8. VP9/AV1 give temporal/spatial
|
||||
// scalability (SVC) so a single stream degrades by layer; H.264/VP8 fall back to
|
||||
// plain simulcast. Values from the brief.
|
||||
export const VIDEO_CONFIG = {
|
||||
codecPreferenceOrder: ['VP9', 'AV1', 'H264', 'VP8'],
|
||||
// Preferred single-encoding SVC mode for VP9 (3 spatial × 3 temporal, key-frame
|
||||
// aligned). If the browser rejects it we fall back to the simulcast ladder below.
|
||||
vp9: {
|
||||
preferredScalabilityMode: 'L3T3_KEY',
|
||||
simulcast: [
|
||||
{ rid: 'low', scaleResolutionDownBy: 4, maxBitrate: 150000, scalabilityMode: 'L1T3' },
|
||||
{ rid: 'mid', scaleResolutionDownBy: 2, maxBitrate: 500000, scalabilityMode: 'L1T3' },
|
||||
{ rid: 'high', scaleResolutionDownBy: 1, maxBitrate: 1500000, scalabilityMode: 'L1T3' },
|
||||
],
|
||||
degradationPreference: 'balanced',
|
||||
},
|
||||
av1: {
|
||||
scalabilityMode: 'L1T3',
|
||||
maxBitrate: 1200000,
|
||||
degradationPreference: 'maintain-framerate',
|
||||
},
|
||||
// H.264 / VP8: ordinary simulcast, no SVC.
|
||||
simulcast: [
|
||||
{ rid: 'low', scaleResolutionDownBy: 4, maxBitrate: 150000 },
|
||||
{ rid: 'mid', scaleResolutionDownBy: 2, maxBitrate: 500000 },
|
||||
{ rid: 'high', scaleResolutionDownBy: 1, maxBitrate: 1500000 },
|
||||
],
|
||||
networkPriority: 'medium', // below audio's 'high'.
|
||||
};
|
||||
|
||||
// ── TRANSPORT (RTCP feedback / header extensions to guarantee in SDP) ────────
|
||||
// transport-wide-cc drives the bandwidth estimator; nack/pli/fir/remb handle
|
||||
// loss recovery and keyframe requests. Video gets the full set, audio a subset.
|
||||
export const TRANSPORT_CONFIG = {
|
||||
twccUri: 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01',
|
||||
video: {
|
||||
rtcpFb: ['transport-cc', 'nack', 'nack pli', 'ccm fir', 'goog-remb'],
|
||||
twcc: true,
|
||||
},
|
||||
audio: {
|
||||
rtcpFb: ['transport-cc', 'nack'],
|
||||
twcc: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── ADAPTATION (getStats loop) ──────────────────────────────────────────────
|
||||
// Reactive bitrate control. Thresholds from the brief. Applied via setParameters
|
||||
// only — never renegotiation, never track restarts.
|
||||
export const ADAPTATION_CONFIG = {
|
||||
intervalMs: 1000,
|
||||
loss: {
|
||||
highPct: 0.10, // >10% loss → back off video.
|
||||
recoverPct: 0.03, // <3% loss (sustained) → ramp up.
|
||||
audioProtectPct: 0.25, // don't touch audio until loss exceeds 25%.
|
||||
},
|
||||
rtt: {
|
||||
highMs: 300, // >300 ms → back off.
|
||||
recoverMs: 150, // <150 ms (sustained) → ramp up.
|
||||
},
|
||||
stepDownPct: 0.20, // shrink video maxBitrate by 20% per bad tick.
|
||||
stepUpPct: 0.10, // grow by 10% per good window.
|
||||
minVideoBitrate: 100000, // floor for the low layer (bps).
|
||||
recoverStableTicks: 5, // consecutive good ticks before ramping up.
|
||||
cpuScaleStep: 1.5, // qualityLimitationReason 'cpu' → bump scaleResolutionDownBy ×1.5.
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
// Pure SDP munging utilities.
|
||||
//
|
||||
// SDP munging is inherently fragile across browsers, so every function here is:
|
||||
// - idempotent (running twice yields the same SDP — no duplicate lines),
|
||||
// - line-ending preserving (SDP uses CRLF; we detect and keep it),
|
||||
// - defensive (unknown/missing sections are left untouched, never thrown on).
|
||||
//
|
||||
// No DOM/WebRTC APIs are used, so this module is unit-testable in plain Node.
|
||||
|
||||
/** Detect the line terminator used by an SDP blob (CRLF per RFC 4566, but be safe). */
|
||||
function detectEol(sdp) {
|
||||
return sdp.indexOf('\r\n') !== -1 ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an SDP into its session part and per-media sections.
|
||||
* @returns {{ eol:string, session:string[], media:Array<{lines:string[]}> }}
|
||||
*/
|
||||
export function splitSdp(sdp) {
|
||||
const eol = detectEol(sdp);
|
||||
const lines = sdp.split(/\r\n|\n/);
|
||||
const session = [];
|
||||
const media = [];
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('m=')) {
|
||||
current = { lines: [line] };
|
||||
media.push(current);
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
session.push(line);
|
||||
}
|
||||
}
|
||||
return { eol, session, media };
|
||||
}
|
||||
|
||||
/** Reassemble a split SDP, preserving the original line ending. */
|
||||
export function joinSdp(parsed) {
|
||||
const all = [...parsed.session];
|
||||
for (const m of parsed.media) all.push(...m.lines);
|
||||
return all.join(parsed.eol);
|
||||
}
|
||||
|
||||
/** The media kind ('audio' | 'video' | 'application') of a section. */
|
||||
export function sectionKind(section) {
|
||||
const m = section.lines[0].match(/^m=(\w+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All payload types in a section whose rtpmap encoding name matches `codecName`
|
||||
* (case-insensitive), e.g. 'opus', 'VP9', 'red'.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findPayloadTypes(section, codecName) {
|
||||
const re = new RegExp('^a=rtpmap:(\\d+)\\s+' + codecName + '\\/', 'i');
|
||||
const pts = [];
|
||||
for (const line of section.lines) {
|
||||
const m = line.match(re);
|
||||
if (m) pts.push(m[1]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Parse an `a=fmtp:<pt> k=v;k2=v2;flag` value string into an ordered map. */
|
||||
function parseFmtpParams(value) {
|
||||
const map = new Map();
|
||||
for (const part of value.split(';')) {
|
||||
const p = part.trim();
|
||||
if (!p) continue;
|
||||
const eq = p.indexOf('=');
|
||||
if (eq === -1) map.set(p, undefined); // bare flag
|
||||
else map.set(p.slice(0, eq).trim(), p.slice(eq + 1).trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Serialise an fmtp param map back to `k=v;k2=v2` form. */
|
||||
function serializeFmtpParams(map) {
|
||||
const parts = [];
|
||||
for (const [k, v] of map) parts.push(v === undefined ? k : `${k}=${v}`);
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update the `a=fmtp:<pt>` line for a payload type, merging `params`
|
||||
* over any existing values. Idempotent: existing keys are overwritten, not
|
||||
* duplicated; a missing fmtp line is created right after the rtpmap line.
|
||||
*/
|
||||
export function upsertFmtp(section, pt, params) {
|
||||
const fmtpIdx = section.lines.findIndex(l => l.startsWith(`a=fmtp:${pt} `) || l === `a=fmtp:${pt}`);
|
||||
if (fmtpIdx !== -1) {
|
||||
const existing = section.lines[fmtpIdx].slice(`a=fmtp:${pt} `.length);
|
||||
const map = parseFmtpParams(existing);
|
||||
for (const [k, v] of Object.entries(params)) map.set(k, String(v));
|
||||
section.lines[fmtpIdx] = `a=fmtp:${pt} ${serializeFmtpParams(map)}`;
|
||||
return;
|
||||
}
|
||||
// No fmtp yet — build one and place it after the matching rtpmap line.
|
||||
const map = new Map();
|
||||
for (const [k, v] of Object.entries(params)) map.set(k, String(v));
|
||||
const newLine = `a=fmtp:${pt} ${serializeFmtpParams(map)}`;
|
||||
const rtpmapIdx = section.lines.findIndex(l => l.startsWith(`a=rtpmap:${pt} `));
|
||||
if (rtpmapIdx !== -1) section.lines.splice(rtpmapIdx + 1, 0, newLine);
|
||||
else section.lines.push(newLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Opus fmtp parameters (FEC / DTX / bitrate / etc.) to every Opus payload
|
||||
* type in the audio m-line. Returns the munged SDP; input SDP with no Opus
|
||||
* audio section is returned unchanged.
|
||||
*/
|
||||
export function applyOpusSettings(sdp, opusFmtp) {
|
||||
if (!sdp || typeof sdp !== 'string') return sdp;
|
||||
const parsed = splitSdp(sdp);
|
||||
let changed = false;
|
||||
for (const section of parsed.media) {
|
||||
if (sectionKind(section) !== 'audio') continue;
|
||||
for (const pt of findPayloadTypes(section, 'opus')) {
|
||||
upsertFmtp(section, pt, opusFmtp);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? joinSdp(parsed) : sdp;
|
||||
}
|
||||
|
||||
// ── Transport feedback ───────────────────────────────────────────────────────
|
||||
|
||||
// Auxiliary payloads that are not primary media codecs (no per-codec rtcp-fb).
|
||||
const AUX_CODEC = /^(rtx|red|ulpfec|flexfec-03|telephone-event|CN)$/i;
|
||||
|
||||
/** Primary-codec payload types in a section (excludes rtx/red/fec/DTMF/CN). */
|
||||
export function getCodecPayloadTypes(section) {
|
||||
const pts = [];
|
||||
for (const line of section.lines) {
|
||||
const m = line.match(/^a=rtpmap:(\d+)\s+([^/]+)\//);
|
||||
if (m && !AUX_CODEC.test(m[2])) pts.push(m[1]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each `a=rtcp-fb:<pt> <fb>` line exists for every primary codec in the
|
||||
* section. Idempotent — never duplicates an existing line.
|
||||
*/
|
||||
export function ensureRtcpFb(section, feedbacks) {
|
||||
for (const pt of getCodecPayloadTypes(section)) {
|
||||
for (const fb of feedbacks) {
|
||||
const line = `a=rtcp-fb:${pt} ${fb}`;
|
||||
if (section.lines.includes(line)) continue;
|
||||
// Insert after the last existing attribute line for this pt.
|
||||
let insertAt = -1;
|
||||
for (let i = 0; i < section.lines.length; i++) {
|
||||
const l = section.lines[i];
|
||||
if (l.startsWith(`a=rtpmap:${pt} `) || l.startsWith(`a=fmtp:${pt} `) || l.startsWith(`a=rtcp-fb:${pt} `)) insertAt = i;
|
||||
}
|
||||
if (insertAt === -1) insertAt = section.lines.length - 1;
|
||||
section.lines.splice(insertAt + 1, 0, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an RTP header extension (`a=extmap:<id> <uri>`) is present, allocating
|
||||
* the next free id. Idempotent — a section already carrying the uri is untouched.
|
||||
*/
|
||||
export function ensureExtmap(section, uri) {
|
||||
if (section.lines.some(l => l.startsWith('a=extmap:') && l.includes(uri))) return;
|
||||
let maxId = 0, insertAt = -1;
|
||||
for (let i = 0; i < section.lines.length; i++) {
|
||||
const m = section.lines[i].match(/^a=extmap:(\d+)/);
|
||||
if (m) { maxId = Math.max(maxId, Number(m[1])); insertAt = i; }
|
||||
}
|
||||
if (insertAt === -1) {
|
||||
// No extmap yet — place after mid/rtpmap area, else end.
|
||||
insertAt = section.lines.findIndex(l => l.startsWith('a=mid:'));
|
||||
if (insertAt === -1) insertAt = section.lines.length - 1;
|
||||
}
|
||||
section.lines.splice(insertAt + 1, 0, `a=extmap:${maxId + 1} ${uri}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure transport-wide-cc / nack / pli / fir / remb feedback and the TWCC header
|
||||
* extension are present on the audio/video m-lines per `cfg` (TRANSPORT_CONFIG).
|
||||
* Idempotent and non-destructive; SDP with no matching sections is returned as-is.
|
||||
*/
|
||||
export function applyTransport(sdp, cfg) {
|
||||
if (!sdp || typeof sdp !== 'string' || !cfg) return sdp;
|
||||
const parsed = splitSdp(sdp);
|
||||
let changed = false;
|
||||
for (const section of parsed.media) {
|
||||
const kind = sectionKind(section);
|
||||
const c = kind === 'video' ? cfg.video : kind === 'audio' ? cfg.audio : null;
|
||||
if (!c) continue;
|
||||
if (Array.isArray(c.rtcpFb)) { ensureRtcpFb(section, c.rtcpFb); changed = true; }
|
||||
if (c.twcc && cfg.twccUri) { ensureExtmap(section, cfg.twccUri); changed = true; }
|
||||
}
|
||||
return changed ? joinSdp(parsed) : sdp;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Video sender / transceiver configuration for calls.
|
||||
//
|
||||
// Strategy for 1:1 P2P (single remote peer): use a SINGLE encoding with SVC
|
||||
// (VP9 L3T3_KEY / AV1 L1T3) applied through sender.setParameters — this needs no
|
||||
// addTransceiver/rids and therefore doesn't disturb the working addTrack flow.
|
||||
// Layered SVC already gives graceful per-layer degradation for one receiver;
|
||||
// multi-rid simulcast (mainly useful for SFUs) is a documented fallback, not
|
||||
// wired here. All calls feature-detect and fall back gracefully:
|
||||
// VP9 → AV1 → H.264 → VP8, and SVC → plain encoding if a mode is rejected.
|
||||
|
||||
import { VIDEO_CONFIG, IS_WEBKIT } from './config.js';
|
||||
|
||||
const CODEC_RANK = { VP9: 0, AV1: 1, H264: 2, VP8: 3 };
|
||||
|
||||
/** 'video/VP9' → 'VP9', 'video/H264' → 'H264', 'video/rtx' → 'RTX', … */
|
||||
export function codecShortName(mimeType) {
|
||||
const sub = String(mimeType || '').split('/')[1] || '';
|
||||
return sub.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a getCapabilities().codecs array so our preferred video codecs come
|
||||
* first (VP9, AV1, H264, VP8), with everything else (rtx / red / *fec) kept in
|
||||
* its original relative order afterwards — dropping rtx/fec would break
|
||||
* retransmission, so they must stay in the list.
|
||||
*/
|
||||
export function sortVideoCodecs(codecs) {
|
||||
const rank = c => {
|
||||
const n = codecShortName(c.mimeType);
|
||||
return Object.prototype.hasOwnProperty.call(CODEC_RANK, n) ? CODEC_RANK[n] : 99;
|
||||
};
|
||||
return codecs
|
||||
.map((c, i) => ({ c, i }))
|
||||
.sort((a, b) => (rank(a.c) - rank(b.c)) || (a.i - b.i)) // stable
|
||||
.map(x => x.c);
|
||||
}
|
||||
|
||||
/** The top available video codec by our preference order, or null. */
|
||||
export function pickPreferredVideoCodec(caps) {
|
||||
if (!caps || !Array.isArray(caps.codecs)) return null;
|
||||
const present = new Set(caps.codecs.map(c => codecShortName(c.mimeType)));
|
||||
for (const name of VIDEO_CONFIG.codecPreferenceOrder) if (present.has(name)) return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
function videoCaps() {
|
||||
return (typeof RTCRtpSender !== 'undefined' && RTCRtpSender.getCapabilities)
|
||||
? RTCRtpSender.getCapabilities('video') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order codec preferences on the video transceiver (VP9 first …). Must be called
|
||||
* before createOffer/createAnswer. Returns the chosen top codec name (or false).
|
||||
* @param {RTCRtpTransceiver} transceiver
|
||||
*/
|
||||
export function applyVideoCodecPreferences(transceiver) {
|
||||
try {
|
||||
if (IS_WEBKIT) return false; // codec preferences skipped on WebKit (capture-safe)
|
||||
if (!transceiver || typeof transceiver.setCodecPreferences !== 'function') return false;
|
||||
const caps = videoCaps();
|
||||
if (!caps || !Array.isArray(caps.codecs)) return false;
|
||||
transceiver.setCodecPreferences(sortVideoCodecs(caps.codecs));
|
||||
return pickPreferredVideoCodec(caps);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the browser confirms the SVC scalabilityMode we want for a codec.
|
||||
* Uses the `scalabilityModes` capability field (Chromium M113+, recent Firefox).
|
||||
* When it's absent we return false so we fall back to simulcast — safer than
|
||||
* assuming SVC on a browser that can't do it (e.g. older Firefox/Safari).
|
||||
*/
|
||||
export function codecSupportsSvc(caps, name) {
|
||||
const codec = caps?.codecs?.find(c => codecShortName(c.mimeType) === name);
|
||||
const modes = codec && codec.scalabilityModes;
|
||||
if (!Array.isArray(modes)) return false;
|
||||
const want = name === 'AV1' ? VIDEO_CONFIG.av1.scalabilityMode : VIDEO_CONFIG.vp9.preferredScalabilityMode;
|
||||
return modes.includes(want);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `sendEncodings` for addTransceiver('video', …) per the brief:
|
||||
* - VP9/AV1 with confirmed SVC → a SINGLE encoding with scalabilityMode
|
||||
* (one stream that degrades by layer — ideal for 1:1),
|
||||
* - VP9 without confirmed SVC → the 3-rid L1T3 simulcast ladder,
|
||||
* - H.264/VP8 (or AV1 w/o SVC) → plain 3-rid simulcast.
|
||||
* @param {RTCRtpCapabilities} [caps] pass to override detection (tests)
|
||||
*/
|
||||
export function buildVideoSendEncodings(caps = videoCaps()) {
|
||||
const preferred = pickPreferredVideoCodec(caps) || 'VP8';
|
||||
if ((preferred === 'VP9' || preferred === 'AV1') && codecSupportsSvc(caps, preferred)) {
|
||||
const plan = encodingPlanFor(preferred);
|
||||
return [{ scalabilityMode: plan.scalabilityMode, maxBitrate: plan.maxBitrate }];
|
||||
}
|
||||
if (preferred === 'VP9') {
|
||||
return VIDEO_CONFIG.vp9.simulcast.map(e => ({ ...e }));
|
||||
}
|
||||
return VIDEO_CONFIG.simulcast.map(e => ({ ...e }));
|
||||
}
|
||||
|
||||
/** SVC mode + bitrate + degradation for a given codec (single-encoding P2P). */
|
||||
export function encodingPlanFor(codecName) {
|
||||
if (codecName === 'VP9') {
|
||||
return { scalabilityMode: VIDEO_CONFIG.vp9.preferredScalabilityMode, maxBitrate: 1500000, degradationPreference: VIDEO_CONFIG.vp9.degradationPreference };
|
||||
}
|
||||
if (codecName === 'AV1') {
|
||||
return { scalabilityMode: VIDEO_CONFIG.av1.scalabilityMode, maxBitrate: VIDEO_CONFIG.av1.maxBitrate, degradationPreference: VIDEO_CONFIG.av1.degradationPreference };
|
||||
}
|
||||
// H.264 / VP8 / unknown: plain single encoding, no SVC.
|
||||
return { scalabilityMode: undefined, maxBitrate: 1500000, degradationPreference: 'balanced' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the single-encoding SVC plan + networkPriority + degradationPreference to
|
||||
* a video sender via setParameters. Falls back to a plain encoding if the browser
|
||||
* rejects the requested scalabilityMode (Firefox/Safari SVC gaps).
|
||||
* @param {RTCRtpSender} sender
|
||||
*/
|
||||
export async function configureVideoSender(sender, options = {}) {
|
||||
try {
|
||||
if (IS_WEBKIT) return false; // sender setParameters skipped on WebKit (capture-safe)
|
||||
if (!sender || typeof sender.getParameters !== 'function') return false;
|
||||
const preferred = pickPreferredVideoCodec(videoCaps()) || 'VP8';
|
||||
const plan = { ...encodingPlanFor(preferred), ...options };
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
|
||||
const simulcast = params.encodings.length > 1;
|
||||
|
||||
if (simulcast) {
|
||||
// Simulcast: keep the per-rid bitrate ladder set at addTransceiver time;
|
||||
// only stamp networkPriority so video stays below audio on the wire.
|
||||
for (const enc of params.encodings) enc.networkPriority = VIDEO_CONFIG.networkPriority;
|
||||
} else {
|
||||
const enc = params.encodings[0];
|
||||
enc.maxBitrate = plan.maxBitrate;
|
||||
enc.networkPriority = VIDEO_CONFIG.networkPriority;
|
||||
if (plan.scalabilityMode) enc.scalabilityMode = plan.scalabilityMode;
|
||||
}
|
||||
if (plan.degradationPreference) params.degradationPreference = plan.degradationPreference;
|
||||
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Most likely an unsupported scalabilityMode → retry without it.
|
||||
if (!simulcast && plan.scalabilityMode) {
|
||||
delete params.encodings[0].scalabilityMode;
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
return true;
|
||||
} catch (e2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user