feat(webrtc): end-to-end encrypted voice & video calls with adaptive codecs
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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:
lockbitchat
2026-07-23 12:56:19 -04:00
parent 0de8ab2d54
commit b3fcf54670
32 changed files with 3855 additions and 86 deletions
+133
View File
@@ -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.
}
}
}
+62
View File
@@ -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';
}