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:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user