UNPKG

rx-player

Version:
448 lines (413 loc) 16 kB
/** * Copyright 2015 CANAL+ Group * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import config from "../../config"; import log from "../../log"; import type { IRepresentation } from "../../manifest"; import arrayFind from "../../utils/array_find"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import getMonotonicTimeStamp from "../../utils/monotonic_timestamp"; import type { IRepresentationEstimatorPlaybackObservation } from "./adaptive_representation_selector"; import type BandwidthEstimator from "./utils/bandwidth_estimator"; import EWMA from "./utils/ewma"; import type { IPendingRequestStoreProgress, IRequestInfo, } from "./utils/pending_requests_store"; /** Object describing the current playback conditions. */ type IPlaybackConditionsInfo = Pick< IRepresentationEstimatorPlaybackObservation, "bufferGap" | "position" | "speed" | "duration" >; /** * Get pending segment request(s) starting with the asked segment position. * @param {Object} requests - Every requests pending, in a chronological * order in terms of segment time. * @param {number} neededPosition * @returns {Array.<Object>} */ function getConcernedRequests( requests: IRequestInfo[], neededPosition: number, ): IRequestInfo[] { /** Index of the request for the next needed segment, in `requests`. */ let nextSegmentIndex = -1; for (let i = 0; i < requests.length; i++) { const { segment } = requests[i].content; if (segment.duration <= 0) { continue; } const segmentEnd = segment.time + segment.duration; if (!segment.complete) { if (i === requests.length - 1 && neededPosition - segment.time > -1.2) { nextSegmentIndex = i; break; } } if (segmentEnd > neededPosition && neededPosition - segment.time > -1.2) { nextSegmentIndex = i; break; } } if (nextSegmentIndex < 0) { // Not found return []; } const nextRequest = requests[nextSegmentIndex]; const segmentTime = nextRequest.content.segment.time; const filteredRequests = [nextRequest]; // Get the possibly multiple requests for that segment's position for (let i = nextSegmentIndex + 1; i < requests.length; i++) { if (requests[i].content.segment.time === segmentTime) { filteredRequests.push(requests[i]); } else { break; } } return filteredRequests; } /** * Estimate the __VERY__ recent bandwidth based on a single unfinished request. * Useful when the current bandwidth seemed to have fallen quickly. * * @param {Object} request * @returns {number|undefined} */ export function estimateRequestBandwidth(request: IRequestInfo): number | undefined { if (request.progress.length < 5) { // threshold from which we can consider // progress events reliably return undefined; } // try to infer quickly the current bitrate based on the // progress events const ewma1 = new EWMA(2); const { progress } = request; for (let i = 1; i < progress.length; i++) { const bytesDownloaded = progress[i].size - progress[i - 1].size; const timeElapsed = progress[i].timestamp - progress[i - 1].timestamp; const reqBitrate = (bytesDownloaded * 8) / (timeElapsed / 1000); ewma1.addSample(timeElapsed / 1000, reqBitrate); } return ewma1.getEstimate(); } /** * Estimate remaining time for a pending request from a progress event. * @param {Object} lastProgressEvent * @param {number} bandwidthEstimate * @returns {number} */ function estimateRemainingTime( lastProgressEvent: IPendingRequestStoreProgress, bandwidthEstimate: number, ): number { const remainingData = (lastProgressEvent.totalSize - lastProgressEvent.size) * 8; return Math.max(remainingData / bandwidthEstimate, 0); } /** * Check if the request for the most needed segment is too slow. * If that's the case, re-calculate the bandwidth urgently based on * this single request. * @param {Object} pendingRequests - Every requests pending, in a chronological * order in terms of segment time. * @param {Object} playbackInfo - Information on the current playback. * @param {Object|null} currentRepresentation - The Representation being * presently being loaded. * @param {boolean} lowLatencyMode - If `true`, we're playing the content as a * low latency content - where requests might be pending when the segment is * still encoded. * @param {Number} lastEstimatedBitrate - Last bitrate estimate emitted. * @returns {Number|undefined} */ function estimateStarvationModeBitrate( pendingRequests: IRequestInfo[], playbackInfo: IPlaybackConditionsInfo, currentRepresentation: IRepresentation | null, lowLatencyMode: boolean, lastEstimatedBitrate: number | undefined, ): number | undefined { if (lowLatencyMode) { // TODO Skip only for newer segments? return undefined; } const { bufferGap, speed, position } = playbackInfo; const realBufferGap = isFinite(bufferGap) ? bufferGap : 0; const nextNeededPosition = position.getWanted() + realBufferGap; const concernedRequests = getConcernedRequests(pendingRequests, nextNeededPosition); if (concernedRequests.length !== 1) { // 0 == no request // 2+ == too complicated to calculate return undefined; } const concernedRequest = concernedRequests[0]; const now = getMonotonicTimeStamp(); let minimumRequestTime = concernedRequest.content.segment.duration * 1.5; minimumRequestTime = Math.min(minimumRequestTime, 3000); minimumRequestTime = Math.max(minimumRequestTime, 12000); if (now - concernedRequest.requestTimestamp < minimumRequestTime) { return undefined; } const lastProgressEvent = concernedRequest.progress.length > 0 ? concernedRequest.progress[concernedRequest.progress.length - 1] : undefined; // first, try to do a quick estimate from progress events const bandwidthEstimate = estimateRequestBandwidth(concernedRequest); if (lastProgressEvent !== undefined && bandwidthEstimate !== undefined) { const remainingTime = estimateRemainingTime(lastProgressEvent, bandwidthEstimate); // if the remaining time does seem reliable if ((now - lastProgressEvent.timestamp) / 1000 <= remainingTime) { // Calculate estimated time spent rebuffering if we continue doing that request. const expectedRebufferingTime = remainingTime - realBufferGap / speed; if (expectedRebufferingTime > 2500) { return bandwidthEstimate; } } } if (!concernedRequest.content.segment.complete) { return undefined; } const chunkDuration = concernedRequest.content.segment.duration; const requestElapsedTime = (now - concernedRequest.requestTimestamp) / 1000; const reasonableElapsedTime = requestElapsedTime <= (chunkDuration * 1.5 + 2) / speed; if (isNullOrUndefined(currentRepresentation) || reasonableElapsedTime) { return undefined; } // calculate a reduced bitrate from the current one const factor = chunkDuration / requestElapsedTime; const reducedBitrate = currentRepresentation.bitrate * Math.min(0.7, factor); if (lastEstimatedBitrate === undefined || reducedBitrate < lastEstimatedBitrate) { return reducedBitrate; } } /** * Returns true if, based on the current requests, it seems that the ABR should * switch immediately if a lower bitrate is more adapted. * Returns false if it estimates that you have time before switching to a lower * bitrate. * @param {Object} playbackInfo - Information on the current playback. * @param {Object} requests - Every requests pending, in a chronological * order in terms of segment time. * @param {boolean} lowLatencyMode - If `true`, we're playing the content as a * low latency content, as close to the live edge as possible. * @returns {boolean} */ function shouldDirectlySwitchToLowBitrate( playbackInfo: IPlaybackConditionsInfo, requests: IRequestInfo[], lowLatencyMode: boolean, ): boolean { if (lowLatencyMode) { // TODO only when playing close to the live edge? return true; } const realBufferGap = isFinite(playbackInfo.bufferGap) ? playbackInfo.bufferGap : 0; const nextNeededPosition = playbackInfo.position.getWanted() + realBufferGap; const nextRequest = arrayFind( requests, ({ content }) => content.segment.duration > 0 && content.segment.time + content.segment.duration > nextNeededPosition, ); if (nextRequest === undefined) { return true; } const now = getMonotonicTimeStamp(); const lastProgressEvent = nextRequest.progress.length > 0 ? nextRequest.progress[nextRequest.progress.length - 1] : undefined; // first, try to do a quick estimate from progress events const bandwidthEstimate = estimateRequestBandwidth(nextRequest); if (lastProgressEvent === undefined || bandwidthEstimate === undefined) { return true; } const remainingTime = estimateRemainingTime(lastProgressEvent, bandwidthEstimate); if ((now - lastProgressEvent.timestamp) / 1000 > remainingTime * 1.2) { return true; } const expectedRebufferingTime = remainingTime - realBufferGap / playbackInfo.speed; return expectedRebufferingTime > -1.5; } /** * Analyze the current network conditions and give a bandwidth estimate as well * as a maximum bitrate a Representation should be. * @class NetworkAnalyzer */ export default class NetworkAnalyzer { private _lowLatencyMode: boolean; private _inStarvationMode: boolean; private _initialBitrate: number; private _config: { starvationGap: number; outOfStarvationGap: number; starvationBitrateFactor: number; regularBitrateFactor: number; }; constructor(initialBitrate: number, lowLatencyMode: boolean) { const { ABR_STARVATION_GAP, OUT_OF_STARVATION_GAP, ABR_STARVATION_FACTOR, ABR_REGULAR_FACTOR, } = config.getCurrent(); this._initialBitrate = initialBitrate; this._inStarvationMode = false; this._lowLatencyMode = lowLatencyMode; if (lowLatencyMode) { this._config = { starvationGap: ABR_STARVATION_GAP.LOW_LATENCY, outOfStarvationGap: OUT_OF_STARVATION_GAP.LOW_LATENCY, starvationBitrateFactor: ABR_STARVATION_FACTOR.LOW_LATENCY, regularBitrateFactor: ABR_REGULAR_FACTOR.LOW_LATENCY, }; } else { this._config = { starvationGap: ABR_STARVATION_GAP.DEFAULT, outOfStarvationGap: OUT_OF_STARVATION_GAP.DEFAULT, starvationBitrateFactor: ABR_STARVATION_FACTOR.DEFAULT, regularBitrateFactor: ABR_REGULAR_FACTOR.DEFAULT, }; } } /** * Gives an estimate of the current bandwidth and of the bitrate that should * be considered for chosing a `representation`. * This estimate is only based on network metrics. * @param {Object} playbackInfo - Gives current information about playback. * @param {Object} bandwidthEstimator - `BandwidthEstimator` allowing to * produce network bandwidth estimates. * @param {Object|null} currentRepresentation - The Representation currently * chosen. * `null` if no Representation has been chosen yet. * @param {Array.<Object>} currentRequests - All segment requests by segment's * start chronological order * @param {number|undefined} lastEstimatedBitrate - Bitrate emitted during the * last estimate. * @returns {Object} */ public getBandwidthEstimate( playbackInfo: IPlaybackConditionsInfo, bandwidthEstimator: BandwidthEstimator, currentRepresentation: IRepresentation | null, currentRequests: IRequestInfo[], lastEstimatedBitrate: number | undefined, ): { bandwidthEstimate?: number | undefined; bitrateChosen: number } { let newBitrateCeil: number | undefined; // bitrate ceil for the chosen Representation let bandwidthEstimate; const localConf = this._config; const { bufferGap, position, duration } = playbackInfo; const realBufferGap = isFinite(bufferGap) ? bufferGap : 0; const { ABR_STARVATION_DURATION_DELTA } = config.getCurrent(); // check if should get in/out of starvation mode if ( isNaN(duration) || realBufferGap + position.getWanted() < duration - ABR_STARVATION_DURATION_DELTA ) { if (!this._inStarvationMode && realBufferGap <= localConf.starvationGap) { log.info("ABR", "enter starvation mode.", { buffergap: realBufferGap, enterStarvation: localConf.starvationGap, }); this._inStarvationMode = true; } else if ( this._inStarvationMode && realBufferGap >= localConf.outOfStarvationGap ) { log.info("ABR", "exit starvation mode.", { bufferGap: realBufferGap, outOfStarvation: localConf.starvationGap, }); this._inStarvationMode = false; } } else if (this._inStarvationMode) { log.info("ABR", "exit starvation mode.", { bufferGap: realBufferGap, }); this._inStarvationMode = false; } // If in starvation mode, check if a quick new estimate can be done // from the last requests. // If so, cancel previous estimates and replace it by the new one if (this._inStarvationMode) { bandwidthEstimate = estimateStarvationModeBitrate( currentRequests, playbackInfo, currentRepresentation, this._lowLatencyMode, lastEstimatedBitrate, ); if (bandwidthEstimate !== undefined) { log.info("ABR", "starvation mode emergency estimate:", { bandwidth: bandwidthEstimate, }); bandwidthEstimator.reset(); newBitrateCeil = isNullOrUndefined(currentRepresentation) ? bandwidthEstimate : Math.min(bandwidthEstimate, currentRepresentation.bitrate); } } // if newBitrateCeil is not yet defined, do the normal estimation if (isNullOrUndefined(newBitrateCeil)) { bandwidthEstimate = bandwidthEstimator.getEstimate(); if (bandwidthEstimate !== undefined) { newBitrateCeil = bandwidthEstimate * (this._inStarvationMode ? localConf.starvationBitrateFactor : localConf.regularBitrateFactor); } else if (lastEstimatedBitrate !== undefined) { newBitrateCeil = lastEstimatedBitrate * (this._inStarvationMode ? localConf.starvationBitrateFactor : localConf.regularBitrateFactor); } else { newBitrateCeil = this._initialBitrate; } } if (playbackInfo.speed > 1) { newBitrateCeil /= playbackInfo.speed; } return { bandwidthEstimate, bitrateChosen: newBitrateCeil }; } /** * For a given wanted bitrate, tells if should switch urgently. * @param {number} bitrate - The new estimated bitrate. * @param {Object|null} currentRepresentation - The Representation being * presently being loaded. * @param {Array.<Object>} currentRequests - All segment requests by segment's * start chronological order * @param {Object} playbackInfo - Information on the current playback. * @returns {boolean} */ public isUrgent( bitrate: number, currentRepresentation: IRepresentation | null, currentRequests: IRequestInfo[], playbackInfo: IPlaybackConditionsInfo, ): boolean { if (currentRepresentation === null) { return true; } else if (bitrate >= currentRepresentation.bitrate) { return false; } return shouldDirectlySwitchToLowBitrate( playbackInfo, currentRequests, this._lowLatencyMode, ); } }