You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

QualityController.ts 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import { getLogger } from '@jitsi/logger';
  2. import JitsiConference from '../../JitsiConference';
  3. import { JitsiConferenceEvents } from '../../JitsiConferenceEvents';
  4. import { CodecMimeType } from '../../service/RTC/CodecMimeType';
  5. import RTCEvents from '../../service/RTC/RTCEvents';
  6. import {
  7. DEFAULT_LAST_N,
  8. LAST_N_UNLIMITED,
  9. VIDEO_CODECS_BY_COMPLEXITY,
  10. VIDEO_QUALITY_LEVELS
  11. } from '../../service/RTC/StandardVideoQualitySettings';
  12. import JitsiLocalTrack from '../RTC/JitsiLocalTrack';
  13. import TraceablePeerConnection from '../RTC/TraceablePeerConnection';
  14. import { isValidNumber } from '../util/MathUtil';
  15. import JingleSessionPC from '../xmpp/JingleSessionPC';
  16. import { CodecSelection } from './CodecSelection';
  17. import ReceiveVideoController from './ReceiveVideoController';
  18. import SendVideoController from './SendVideoController';
  19. const logger = getLogger('modules/qualitycontrol/QualityController');
  20. // Period for which the client will wait for the cpu limitation flag to be reset in the peerconnection stats before it
  21. // attempts to rectify the situation by attempting a codec switch.
  22. const LIMITED_BY_CPU_TIMEOUT = 60000;
  23. // The min. value that lastN will be set to while trying to fix video qaulity issues.
  24. const MIN_LAST_N = 3;
  25. enum QualityLimitationReason {
  26. BANDWIDTH = 'bandwidth',
  27. CPU = 'cpu',
  28. NONE = 'none'
  29. }
  30. interface IResolution {
  31. height: number;
  32. width: number;
  33. }
  34. interface IOutboundRtpStats {
  35. codec: CodecMimeType;
  36. encodeTime: number;
  37. qualityLimitationReason: QualityLimitationReason;
  38. resolution: IResolution;
  39. timestamp: number;
  40. }
  41. interface ISourceStats {
  42. avgEncodeTime: number;
  43. codec: CodecMimeType;
  44. encodeResolution: number;
  45. localTrack: JitsiLocalTrack;
  46. qualityLimitationReason: QualityLimitationReason;
  47. timestamp: number;
  48. tpc: TraceablePeerConnection;
  49. }
  50. interface ITrackStats {
  51. encodeResolution: number;
  52. encodeTime: number;
  53. qualityLimitationReason: QualityLimitationReason;
  54. }
  55. interface IVideoConstraints {
  56. maxHeight: number;
  57. sourceName: string;
  58. }
  59. /* eslint-disable require-jsdoc */
  60. export class FixedSizeArray {
  61. private _data: ISourceStats[];
  62. private _maxSize: number;
  63. constructor(size: number) {
  64. this._maxSize = size;
  65. this._data = [];
  66. }
  67. add(item: ISourceStats): void {
  68. if (this._data.length >= this._maxSize) {
  69. this._data.shift();
  70. }
  71. this._data.push(item);
  72. }
  73. get(index: number): ISourceStats | undefined {
  74. if (index < 0 || index >= this._data.length) {
  75. throw new Error('Index out of bounds');
  76. }
  77. return this._data[index];
  78. }
  79. size(): number {
  80. return this._data.length;
  81. }
  82. }
  83. /* eslint-enable require-jsdoc */
  84. /**
  85. * QualityController class that is responsible for maintaining optimal video quality experience on the local endpoint
  86. * by controlling the codec, encode resolution and receive resolution of the remote video streams. It also makes
  87. * adjustments based on the outbound and inbound rtp stream stats reported by the underlying peer connection.
  88. */
  89. export class QualityController {
  90. private _codecController: CodecSelection;
  91. private _conference: JitsiConference;
  92. private _enableAdaptiveMode: boolean;
  93. private _encodeTimeStats: Map<number, FixedSizeArray>;
  94. private _isLastNRampupBlocked: boolean;
  95. private _lastNRampupTime: number;
  96. private _lastNRampupTimeout: number | undefined;
  97. private _limitedByCpuTimeout: number | undefined;
  98. private _receiveVideoController: ReceiveVideoController;
  99. private _sendVideoController: SendVideoController;
  100. /**
  101. *
  102. * @param {JitsiConference} conference - The JitsiConference instance.
  103. * @param {Object} options - video quality settings passed through config.js.
  104. */
  105. constructor(conference: JitsiConference, options: {
  106. enableAdaptiveMode: boolean;
  107. jvb: object;
  108. lastNRampupTime: number;
  109. p2p: object;
  110. }) {
  111. this._conference = conference;
  112. const { jvb, p2p } = options;
  113. this._codecController = new CodecSelection(conference, { jvb,
  114. p2p });
  115. this._enableAdaptiveMode = options.enableAdaptiveMode ?? true;
  116. this._encodeTimeStats = new Map();
  117. this._isLastNRampupBlocked = false;
  118. this._lastNRampupTime = options.lastNRampupTime;
  119. this._receiveVideoController = new ReceiveVideoController(conference);
  120. this._sendVideoController = new SendVideoController(conference);
  121. this._conference.on(
  122. JitsiConferenceEvents._MEDIA_SESSION_STARTED,
  123. (session: JingleSessionPC) => {
  124. this._codecController.selectPreferredCodec(session);
  125. this._receiveVideoController.onMediaSessionStarted(session);
  126. this._sendVideoController.onMediaSessionStarted(session);
  127. });
  128. this._conference.on(
  129. JitsiConferenceEvents._MEDIA_SESSION_ACTIVE_CHANGED,
  130. () => this._sendVideoController.configureConstraintsForLocalSources());
  131. this._conference.on(
  132. JitsiConferenceEvents.CONFERENCE_VISITOR_CODECS_CHANGED,
  133. (codecList: CodecMimeType[]) => this._codecController.updateVisitorCodecs(codecList));
  134. // Debounce the calls to codec selection when there is a burst of joins and leaves.
  135. const debouncedSelectCodec = this._debounce(
  136. () => this._codecController.selectPreferredCodec(this._conference.jvbJingleSession),
  137. 1000);
  138. this._conference.on(JitsiConferenceEvents.USER_JOINED, debouncedSelectCodec.bind(this));
  139. this._conference.on(JitsiConferenceEvents.USER_LEFT, debouncedSelectCodec.bind(this));
  140. this._conference.rtc.on(
  141. RTCEvents.SENDER_VIDEO_CONSTRAINTS_CHANGED,
  142. (videoConstraints: IVideoConstraints) =>
  143. this._sendVideoController.onSenderConstraintsReceived(videoConstraints));
  144. this._conference.on(
  145. JitsiConferenceEvents.ENCODE_TIME_STATS_RECEIVED,
  146. (tpc: TraceablePeerConnection, stats: Map<number, IOutboundRtpStats>) =>
  147. this._processOutboundRtpStats(tpc, stats));
  148. }
  149. /**
  150. * Creates a debounced function that delays the execution of the provided function until after the specified delay
  151. * has elapsed. Unlike typical debounce implementations, the timer does not reset when the function is called again
  152. * within the delay period.
  153. *
  154. * @param {Function} func - The function to be debounced.
  155. * @param {number} delay - The delay in milliseconds.
  156. * @returns {Function} - The debounced function.
  157. */
  158. _debounce(func: Function, delay: number) { // eslint-disable-line @typescript-eslint/no-unsafe-function-type
  159. return function(...args: any) {
  160. /* eslint-disable @typescript-eslint/no-invalid-this */
  161. if (!this._timer) {
  162. this._timer = setTimeout(() => {
  163. this._timer = null;
  164. func.apply(this, args);
  165. }, delay);
  166. }
  167. /* eslint-enable @typescript-eslint/no-invalid-this */
  168. };
  169. }
  170. /**
  171. * Adjusts the lastN value so that fewer remote video sources are received from the bridge in an attempt to improve
  172. * encode resolution of the outbound video streams based on cpuLimited parameter passed. If cpuLimited is false,
  173. * the lastN value will slowly be ramped back up to the channelLastN value set in config.js.
  174. *
  175. * @param {boolean} cpuLimited - whether the endpoint is cpu limited or not.
  176. * @returns boolean - Returns true if an action was taken, false otherwise.
  177. */
  178. _lowerOrRaiseLastN(cpuLimited: boolean): boolean {
  179. const lastN = this.receiveVideoController.getLastN();
  180. let newLastN = lastN;
  181. if (cpuLimited && (lastN !== LAST_N_UNLIMITED && lastN <= MIN_LAST_N)) {
  182. return false;
  183. }
  184. // If channelLastN is not set or set to -1 in config.js, the client will ramp up lastN to only up to 25.
  185. let { channelLastN = DEFAULT_LAST_N } = this._conference.options.config;
  186. channelLastN = channelLastN === LAST_N_UNLIMITED ? DEFAULT_LAST_N : channelLastN;
  187. if (cpuLimited) {
  188. const videoStreamsReceived = this._conference.getForwardedSources().length;
  189. newLastN = Math.floor(videoStreamsReceived / 2);
  190. if (newLastN < MIN_LAST_N) {
  191. newLastN = MIN_LAST_N;
  192. }
  193. // Increment lastN by 1 every LAST_N_RAMPUP_TIME (60) secs.
  194. } else if (lastN < channelLastN) {
  195. newLastN++;
  196. }
  197. if (newLastN === lastN) {
  198. return false;
  199. }
  200. const isStillLimitedByCpu = newLastN < channelLastN;
  201. this.receiveVideoController.setLastNLimitedByCpu(isStillLimitedByCpu);
  202. logger.info(`QualityController - setting lastN=${newLastN}, limitedByCpu=${isStillLimitedByCpu}`);
  203. this.receiveVideoController.setLastN(newLastN);
  204. return true;
  205. }
  206. /**
  207. * Adjusts the requested resolution for remote video sources by updating the receiver constraints in an attempt to
  208. * improve the encode resolution of the outbound video streams.
  209. * @return {void}
  210. */
  211. _maybeLowerReceiveResolution(): void {
  212. const currentConstraints = this.receiveVideoController.getCurrentReceiverConstraints();
  213. const individualConstraints = currentConstraints.constraints;
  214. let maxHeight = 0;
  215. if (individualConstraints && Object.keys(individualConstraints).length) {
  216. for (const value of Object.values(individualConstraints)) {
  217. const v: any = value;
  218. maxHeight = Math.max(maxHeight, v.maxHeight);
  219. }
  220. }
  221. const currentLevel = VIDEO_QUALITY_LEVELS.findIndex(lvl => lvl.height <= maxHeight);
  222. // Do not lower the resolution to less than 180p.
  223. if (VIDEO_QUALITY_LEVELS[currentLevel].height === 180) {
  224. return;
  225. }
  226. this.receiveVideoController.setPreferredReceiveMaxFrameHeight(VIDEO_QUALITY_LEVELS[currentLevel + 1].height);
  227. }
  228. /**
  229. * Updates the codec preference order for the local endpoint on the active media session and switches the video
  230. * codec if needed.
  231. *
  232. * @param {number} trackId - The track ID of the local video track for which stats have been captured.
  233. * @returns {boolean} - Returns true if video codec was changed.
  234. */
  235. _maybeSwitchVideoCodec(trackId: number): boolean {
  236. const stats = this._encodeTimeStats.get(trackId);
  237. const { codec, encodeResolution, localTrack } = stats.get(stats.size() - 1);
  238. const codecsByVideoType = VIDEO_CODECS_BY_COMPLEXITY[localTrack.getVideoType()];
  239. const codecIndex = codecsByVideoType.findIndex(val => val === codec.toLowerCase());
  240. // Do nothing if the encoder is using the lowest complexity codec already.
  241. if (codecIndex === codecsByVideoType.length - 1) {
  242. return false;
  243. }
  244. if (!this._limitedByCpuTimeout) {
  245. this._limitedByCpuTimeout = window.setTimeout(() => {
  246. this._limitedByCpuTimeout = undefined;
  247. const updatedStats = this._encodeTimeStats.get(trackId);
  248. const latestSourceStats: ISourceStats = updatedStats.get(updatedStats.size() - 1);
  249. const expectedSendResolution
  250. = Math.min(localTrack.maxEnabledResolution, localTrack.getCaptureResolution());
  251. // If the encoder is still limited by CPU, switch to a lower complexity codec.
  252. if (latestSourceStats.qualityLimitationReason === QualityLimitationReason.CPU
  253. || encodeResolution < expectedSendResolution) {
  254. return this.codecController.changeCodecPreferenceOrder(localTrack, codec);
  255. }
  256. }, LIMITED_BY_CPU_TIMEOUT);
  257. }
  258. return false;
  259. }
  260. /**
  261. * Adjusts codec, lastN or receive resolution based on the send resolution (of the outbound streams) and limitation
  262. * reported by the browser in the WebRTC stats. Recovery is also attempted if the limitation goes away. No action
  263. * is taken if the adaptive mode has been disabled through config.js.
  264. *
  265. * @param {ISourceStats} sourceStats - The outbound-rtp stats for a local video track.
  266. * @returns {void}
  267. */
  268. _performQualityOptimizations(sourceStats: ISourceStats): void {
  269. // Do not attempt run time adjustments if the adaptive mode is disabled.
  270. if (!this._enableAdaptiveMode) {
  271. return;
  272. }
  273. const { encodeResolution, localTrack, qualityLimitationReason, tpc } = sourceStats;
  274. // Older browser versions might not report the resolution in the stats.
  275. if (!isValidNumber(encodeResolution)) {
  276. return;
  277. }
  278. const trackId = localTrack.rtcId;
  279. if (encodeResolution === tpc.calculateExpectedSendResolution(localTrack)) {
  280. if (this._limitedByCpuTimeout) {
  281. window.clearTimeout(this._limitedByCpuTimeout);
  282. this._limitedByCpuTimeout = undefined;
  283. }
  284. if (qualityLimitationReason === QualityLimitationReason.NONE
  285. && this.receiveVideoController.isLastNLimitedByCpu()) {
  286. if (!this._lastNRampupTimeout && !this._isLastNRampupBlocked) {
  287. // Ramp up the number of received videos if CPU limitation no longer exists. If the cpu
  288. // limitation returns as a consequence, do not attempt to ramp up again, continue to
  289. // increment the lastN value otherwise until it is equal to the channelLastN value.
  290. this._lastNRampupTimeout = window.setTimeout(() => {
  291. this._lastNRampupTimeout = undefined;
  292. const updatedStats = this._encodeTimeStats.get(trackId);
  293. const latestSourceStats: ISourceStats = updatedStats.get(updatedStats.size() - 1);
  294. if (latestSourceStats.qualityLimitationReason === QualityLimitationReason.CPU) {
  295. this._isLastNRampupBlocked = true;
  296. } else {
  297. this._lowerOrRaiseLastN(false /* raise */);
  298. }
  299. }, this._lastNRampupTime);
  300. }
  301. }
  302. return;
  303. }
  304. // Do nothing if the limitation reason is bandwidth since the browser will dynamically adapt the outbound
  305. // resolution based on available uplink bandwith. Otherwise,
  306. // 1. Switch the codec to the lowest complexity one incrementally.
  307. // 2. Switch to a lower lastN value, cutting the receive videos by half in every iteration until
  308. // MIN_LAST_N value is reached.
  309. // 3. Lower the receive resolution of individual streams up to 180p.
  310. if (qualityLimitationReason === QualityLimitationReason.CPU) {
  311. if (this._lastNRampupTimeout) {
  312. window.clearTimeout(this._lastNRampupTimeout);
  313. this._lastNRampupTimeout = undefined;
  314. this._isLastNRampupBlocked = true;
  315. }
  316. const codecSwitched = this._maybeSwitchVideoCodec(trackId);
  317. if (!codecSwitched && !this._limitedByCpuTimeout) {
  318. const lastNChanged = this._lowerOrRaiseLastN(true /* lower */);
  319. if (!lastNChanged) {
  320. this.receiveVideoController.setReceiveResolutionLimitedByCpu(true);
  321. this._maybeLowerReceiveResolution();
  322. }
  323. }
  324. }
  325. }
  326. /**
  327. * Processes the outbound RTP stream stats as reported by the WebRTC peerconnection and makes runtime adjustments
  328. * to the client for better quality experience if the adaptive mode is enabled.
  329. *
  330. * @param {TraceablePeerConnection} tpc - The underlying WebRTC peerconnection where stats have been captured.
  331. * @param {Map<number, IOutboundRtpStats>} stats - Outbound-rtp stream stats per SSRC.
  332. * @returns void
  333. */
  334. _processOutboundRtpStats(tpc: TraceablePeerConnection, stats: Map<number, IOutboundRtpStats>): void {
  335. const activeSession = this._conference.getActiveMediaSession();
  336. // Process stats only for the active media session.
  337. if (activeSession.peerconnection !== tpc) {
  338. return;
  339. }
  340. const statsPerTrack = new Map();
  341. for (const ssrc of stats.keys()) {
  342. const { codec, encodeTime, qualityLimitationReason, resolution, timestamp } = stats.get(ssrc);
  343. const track = tpc.getTrackBySSRC(ssrc);
  344. const trackId = track.rtcId;
  345. let existingStats = statsPerTrack.get(trackId);
  346. const encodeResolution = Math.min(resolution?.height, resolution?.width);
  347. const ssrcStats = {
  348. encodeResolution,
  349. encodeTime,
  350. qualityLimitationReason
  351. };
  352. if (existingStats) {
  353. existingStats.codec = codec;
  354. existingStats.timestamp = timestamp;
  355. existingStats.trackStats.push(ssrcStats);
  356. } else {
  357. existingStats = {
  358. codec,
  359. timestamp,
  360. trackStats: [ ssrcStats ]
  361. };
  362. statsPerTrack.set(trackId, existingStats);
  363. }
  364. }
  365. // Aggregate the stats for multiple simulcast streams with different SSRCs but for the same video stream.
  366. for (const trackId of statsPerTrack.keys()) {
  367. const { codec, timestamp, trackStats } = statsPerTrack.get(trackId);
  368. const totalEncodeTime = trackStats
  369. .map((stat: ITrackStats) => stat.encodeTime)
  370. .reduce((totalValue: number, currentValue: number) => totalValue + currentValue, 0);
  371. const avgEncodeTime: number = totalEncodeTime / trackStats.length;
  372. const { qualityLimitationReason = QualityLimitationReason.NONE }
  373. = trackStats
  374. .find((stat: ITrackStats) => stat.qualityLimitationReason !== QualityLimitationReason.NONE) ?? {};
  375. const encodeResolution: number = trackStats
  376. .map((stat: ITrackStats) => stat.encodeResolution)
  377. .reduce((resolution: number, currentValue: number) => Math.max(resolution, currentValue), 0);
  378. const localTrack = this._conference.getLocalVideoTracks().find(t => t.rtcId === trackId);
  379. const exisitingStats: FixedSizeArray = this._encodeTimeStats.get(trackId);
  380. const sourceStats = {
  381. avgEncodeTime,
  382. codec,
  383. encodeResolution,
  384. qualityLimitationReason,
  385. localTrack,
  386. timestamp,
  387. tpc
  388. };
  389. if (exisitingStats) {
  390. exisitingStats.add(sourceStats);
  391. } else {
  392. // Save stats for only the last 5 mins.
  393. const data = new FixedSizeArray(300);
  394. data.add(sourceStats);
  395. this._encodeTimeStats.set(trackId, data);
  396. }
  397. logger.debug(`Encode stats for ${localTrack}: codec=${codec}, time=${avgEncodeTime},`
  398. + `resolution=${encodeResolution}, qualityLimitationReason=${qualityLimitationReason}`);
  399. this._performQualityOptimizations(sourceStats);
  400. }
  401. }
  402. /**
  403. * Gets the codec controller instance.
  404. */
  405. get codecController() {
  406. return this._codecController;
  407. }
  408. /**
  409. * Gets the receive video controller instance.
  410. */
  411. get receiveVideoController() {
  412. return this._receiveVideoController;
  413. }
  414. /**
  415. * Gets the send video controller instance.
  416. */
  417. get sendVideoController() {
  418. return this._sendVideoController;
  419. }
  420. }