UNPKG

vani-meeting-client

Version:
405 lines (404 loc) 20.5 kB
// MediaAdaptationManager.ts var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __values = (this && this.__values) || function(o) { var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; if (m) return m.call(o); if (o && typeof o.length === "number") return { next: function () { if (o && i >= o.length) o = void 0; return { value: o && o[i++], done: !o }; } }; throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); }; var _a; import { DeviceTier } from '../model/MeetingStartRequest'; // ─── Network caps ──────────────────────────────────────────────── // Controls what the network can handle. // These are upper bounds — device tier can only make them lower. var NETWORK_CAPS = { good: { videoEnabled: true, videoWidth: 640, videoHeight: 480, videoFrameRate: 24, opusBitrate: 40000, opusPtime: 10, opusFec: true, opusDtx: true, opusMaxPlaybackRate: 16000, }, degraded: { videoEnabled: true, videoWidth: 360, videoHeight: 270, videoFrameRate: 15, opusBitrate: 24000, opusPtime: 10, opusFec: true, opusDtx: true, opusMaxPlaybackRate: 16000, }, poor: { // ptime=20 → 50 pkts/sec instead of 100 at ptime=10. // Same audio data, half the UDP header overhead per second. // Critical on mobile where each packet competes for radio time. videoEnabled: true, videoWidth: 180, videoHeight: 144, videoFrameRate: 8, opusBitrate: 16000, opusPtime: 20, opusFec: true, opusDtx: true, opusMaxPlaybackRate: 8000, }, critical: { // Video off entirely — zero encoder work, zero video bandwidth. // All CPU and bandwidth goes to keeping audio alive. // ptime=40 → FEC in adjacent packets reconstructs 40ms of lost // audio. At 30% packet loss this keeps voice intelligible. videoEnabled: false, videoWidth: 180, videoHeight: 144, videoFrameRate: 8, opusBitrate: 8000, opusPtime: 40, opusFec: true, opusDtx: true, opusMaxPlaybackRate: 8000, }, }; // ─── Device caps ───────────────────────────────────────────────── // Controls what the hardware can handle regardless of network. // Low RAM device = conservative caps even on perfect network. var DEVICE_CAPS = (_a = {}, _a[DeviceTier.high] = { videoWidth: 640, videoHeight: 480, videoFrameRate: 24, // complexity=9: best quality, encoder does deep search per frame. // Only viable on high-end hardware with thermal headroom. opusComplexity: 9, maxOpusBitrate: 40000, maxOpusPlaybackRate: 16000, }, _a[DeviceTier.mid] = { // 360p cap on mid: mid-range Android encoders at 640p cause // thermal throttling after ~5 minutes which is worse than // starting conservatively. videoWidth: 360, videoHeight: 270, videoFrameRate: 15, // complexity=6: ~20% less CPU than 9, no audible voice difference. opusComplexity: 6, maxOpusBitrate: 24000, maxOpusPlaybackRate: 16000, }, _a[DeviceTier.low] = { // Low RAM = slow CPU too. Cap hard at 180p from the start. // Never let network profile push this higher — the hardware // cannot sustain it without dropouts. videoWidth: 180, videoHeight: 144, videoFrameRate: 10, // complexity=4: ~35% less CPU than 9. // Voice calls at complexity 4 are indistinguishable from 9 // for most listeners. This is the right tradeoff. opusComplexity: 4, maxOpusBitrate: 16000, maxOpusPlaybackRate: 8000, }, _a); // ─── Merge function ─────────────────────────────────────────────── // Pure function — no side effects, easy to unit test. // Rule: always take the MORE restrictive of network vs device. // Network controls video on/off and ptime. // Device controls complexity. // Everything else: Math.min wins. var resolveConfig = function (network, device) { return ({ videoEnabled: network.videoEnabled, videoWidth: Math.min(network.videoWidth, device.videoWidth), videoHeight: Math.min(network.videoHeight, device.videoHeight), videoFrameRate: Math.min(network.videoFrameRate, device.videoFrameRate), opusBitrate: Math.min(network.opusBitrate, device.maxOpusBitrate), opusPtime: network.opusPtime, opusFec: network.opusFec, opusDtx: network.opusDtx, opusMaxPlaybackRate: Math.min(network.opusMaxPlaybackRate, device.maxOpusPlaybackRate), opusComplexity: device.opusComplexity, // device-driven only }); }; // ─── Hysteresis ─────────────────────────────────────────────────── // Degrade fast → protect audio quality quickly (2 samples = ~6s) // Recover slow → avoid thrashing on a fluctuating mobile signal (6 samples = ~18s) var SAMPLES_TO_DEGRADE = 2; var SAMPLES_TO_UPGRADE = 6; // ─── Class ──────────────────────────────────────────────────────── var MediaAdaptationManager = /** @class */ (function () { function MediaAdaptationManager(getPeerConnections, getVideoTrack, onProfileChange, deviceTier) { if (deviceTier === void 0) { deviceTier = DeviceTier.mid; } var _this = this; this.getPeerConnections = getPeerConnections; this.getVideoTrack = getVideoTrack; this.onProfileChange = onProfileChange; this.deviceTier = deviceTier; this.networkProfile = 'good'; this.handle = null; this.tickCount = 0; this.badSamples = 0; this.goodSamples = 0; // ─── Stats tick ────────────────────────────────────────────── this.tick = function () { return __awaiter(_this, void 0, void 0, function () { var stats, target, worse, better; return __generator(this, function (_a) { switch (_a.label) { case 0: this.tickCount++; // Skip first 4 ticks (~12s). // STUN candidate-pair RTT needs ~10s to stabilise. // Reading before that gives RTT=0 → false 'good' classification. this.handle = undefined; this.start(); if (this.tickCount < 4) return [2 /*return*/]; return [4 /*yield*/, this.collectStats()]; case 1: stats = _a.sent(); target = this.classify(stats); worse = this.rank(target) > this.rank(this.networkProfile); better = this.rank(target) < this.rank(this.networkProfile); if (worse) { this.goodSamples = 0; this.badSamples++; if (this.badSamples >= SAMPLES_TO_DEGRADE) { this.badSamples = 0; this.switchProfile(target); } } else if (better) { this.badSamples = 0; this.goodSamples++; if (this.goodSamples >= SAMPLES_TO_UPGRADE) { this.goodSamples = 0; this.switchProfile(this.stepUp(this.networkProfile)); } } else { // Exactly matches current profile — reset both counters. // Prevents partial counts accumulating across mixed samples. this.badSamples = 0; this.goodSamples = 0; } return [2 /*return*/]; } }); }); }; // ─── Stats collection ──────────────────────────────────────── this.collectStats = function () { return __awaiter(_this, void 0, void 0, function () { var totalRtt, totalJitter, totalLoss, rttCount, lossCount, _a, _b, pc, report, _1, e_1_1; var e_1, _c; return __generator(this, function (_d) { switch (_d.label) { case 0: totalRtt = 0, totalJitter = 0, totalLoss = 0; rttCount = 0, lossCount = 0; _d.label = 1; case 1: _d.trys.push([1, 8, 9, 10]); _a = __values(this.getPeerConnections()), _b = _a.next(); _d.label = 2; case 2: if (!!_b.done) return [3 /*break*/, 7]; pc = _b.value; if (pc.connectionState !== 'connected') return [3 /*break*/, 6]; _d.label = 3; case 3: _d.trys.push([3, 5, , 6]); return [4 /*yield*/, pc.getStats() // forEach is safe across all react-native-webrtc versions. // .values() requires downlevelIteration or es2015+ target. ]; case 4: report = _d.sent(); // forEach is safe across all react-native-webrtc versions. // .values() requires downlevelIteration or es2015+ target. report.forEach(function (r) { var _a, _b, _c, _d; if (r.type === 'candidate-pair' && r.nominated === true && r.currentRoundTripTime != null) { totalRtt += r.currentRoundTripTime * 1000; // → ms rttCount++; } if (r.type === 'inbound-rtp' && r.kind === 'audio') { totalJitter += ((_a = r.jitter) !== null && _a !== void 0 ? _a : 0) * 1000; // → ms var total = ((_b = r.packetsReceived) !== null && _b !== void 0 ? _b : 0) + ((_c = r.packetsLost) !== null && _c !== void 0 ? _c : 0); if (total > 0) { totalLoss += (((_d = r.packetsLost) !== null && _d !== void 0 ? _d : 0) / total) * 100; lossCount++; } } }); return [3 /*break*/, 6]; case 5: _1 = _d.sent(); return [3 /*break*/, 6]; case 6: _b = _a.next(); return [3 /*break*/, 2]; case 7: return [3 /*break*/, 10]; case 8: e_1_1 = _d.sent(); e_1 = { error: e_1_1 }; return [3 /*break*/, 10]; case 9: try { if (_b && !_b.done && (_c = _a.return)) _c.call(_a); } finally { if (e_1) throw e_1.error; } return [7 /*endfinally*/]; case 10: return [2 /*return*/, { rtt: rttCount ? totalRtt / rttCount : 0, lossPercent: lossCount ? totalLoss / lossCount : 0, jitter: lossCount ? totalJitter / lossCount : 0, }]; } }); }); }; // ─── Helpers ───────────────────────────────────────────────── this.classify = function (s) { if (s.rtt > 800 || s.lossPercent > 20 || s.jitter > 100) return 'critical'; if (s.rtt > 400 || s.lossPercent > 8 || s.jitter > 50) return 'poor'; if (s.rtt > 150 || s.lossPercent > 2 || s.jitter > 20) return 'degraded'; return 'good'; }; this.resolved = function () { return resolveConfig(NETWORK_CAPS[_this.networkProfile], DEVICE_CAPS[_this.deviceTier]); }; this.rank = function (p) { return ({ good: 0, degraded: 1, poor: 2, critical: 3 }[p]); }; this.stepUp = function (p) { return ({ good: 'good', degraded: 'good', poor: 'degraded', critical: 'poor' }[p]); }; } // ─── Lifecycle ─────────────────────────────────────────────── MediaAdaptationManager.prototype.start = function () { return __awaiter(this, void 0, void 0, function () { var nonClosedPeerConnection; return __generator(this, function (_a) { if (this.handle) { return [2 /*return*/]; } nonClosedPeerConnection = this.getPeerConnections().find(function (eachPeerConnection) { return eachPeerConnection.connectionState !== 'closed'; }); if (nonClosedPeerConnection) { this.handle = setTimeout(this.tick, 3000); } return [2 /*return*/]; }); }); }; MediaAdaptationManager.prototype.stop = function () { if (this.handle) clearTimeout(this.handle); }; // Call when a new peer connection reaches 'connected'. // setParameters is not implemented in react-native-webrtc so // there is nothing connection-specific to apply. // Video constraints live on the shared track — already applied. MediaAdaptationManager.prototype.applyToNewConnection = function (_pc) { this.applyVideo(this.resolved()); }; // ─── Profile switch ────────────────────────────────────────── MediaAdaptationManager.prototype.switchProfile = function (profile) { if (profile === this.networkProfile) return; console.log("[Adapt] ".concat(this.networkProfile, " \u2192 ").concat(profile)); this.networkProfile = profile; var config = this.resolved(); this.applyVideo(config); this.onProfileChange(profile, config); }; // ─── Video apply ───────────────────────────────────────────── MediaAdaptationManager.prototype.applyVideo = function (config) { var track = this.getVideoTrack(); if (!track) return; // enabled=false stops encoder immediately, no signalling needed. // Do this first — if disabling, no point calling applyConstraints. track.enabled = config.videoEnabled; if (!config.videoEnabled) return; track.applyConstraints({ width: { ideal: config.videoWidth }, height: { ideal: config.videoHeight }, frameRate: { ideal: config.videoFrameRate }, }).catch(function (e) { // Non-fatal — camera may not support the requested resolution. // Track continues at whatever constraints it currently has. console.warn('[Adapt] applyConstraints failed', e); }); }; // ─── SDP munge ─────────────────────────────────────────────── // Called from WebrtcHandler.setOpusSdpParams at offer/answer time. // This is the ONLY way to set Opus bitrate in react-native-webrtc. // setParameters() is not implemented — SDP fmtp is the contract. MediaAdaptationManager.prototype.buildOpusFmtp = function (payloadType) { var c = this.resolved(); var params = [ "minptime=".concat(c.opusPtime), "maxaveragebitrate=".concat(c.opusBitrate), "useinbandfec=".concat(c.opusFec ? 1 : 0), "usedtx=".concat(c.opusDtx ? 1 : 0), "stereo=0", "maxplaybackrate=".concat(c.opusMaxPlaybackRate), "complexity=".concat(c.opusComplexity), // No cbr — VBR always on mobile. // DTX + VBR = silence costs zero bytes and zero radio time. ].join(';'); return "a=fmtp:".concat(payloadType, " ").concat(params); }; MediaAdaptationManager.prototype.getCurrentProfile = function () { return this.networkProfile; }; MediaAdaptationManager.prototype.getCurrentResolved = function () { return this.resolved(); }; return MediaAdaptationManager; }()); export { MediaAdaptationManager };