vani-meeting-client
Version:
Vani Meeting Clinet SDK
405 lines (404 loc) • 20.5 kB
JavaScript
// 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 };