Source: streaming/controllers/ThroughputController.js

/**
 * The copyright in this software is being made available under the BSD License,
 * included below. This software may be subject to other third party and contributor
 * rights, including patent rights, and no such rights are granted under this license.
 *
 * Copyright (c) 2017, Dash Industry Forum.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *  * Redistributions of source code must retain the above copyright notice, this
 *  list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *  this list of conditions and the following disclaimer in the documentation and/or
 *  other materials provided with the distribution.
 *  * Neither the name of Dash Industry Forum nor the names of its
 *  contributors may be used to endorse or promote products derived from this software
 *  without specific prior written permission.
 *
 *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
 *  EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 *  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 *  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 *  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 *  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 *  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 *  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 *  POSSIBILITY OF SUCH DAMAGE.
 */

import Constants from '../constants/Constants.js';
import FactoryMaker from '../../core/FactoryMaker.js';
import ThroughputModel from '../models/ThroughputModel.js';
import MetricsConstants from '../constants/MetricsConstants.js';
import {HTTPRequest} from '../vo/metrics/HTTPRequest.js';
import MediaPlayerEvents from '../MediaPlayerEvents.js';
import EventBus from '../../core/EventBus.js';

/**
 * @constructor
 */
function ThroughputController() {

    const context = this.context;
    const eventBus = EventBus(context).getInstance();

    let throughputModel,
        playbackController,
        settings;

    function initialize() {
        throughputModel = ThroughputModel(context).create({
            settings
        });
        _registerEvents();
    }

    function setConfig(config) {
        if (config.settings) {
            settings = config.settings;
        }

        if (config.playbackController) {
            playbackController = config.playbackController;
        }
    }

    function _registerEvents() {
        eventBus.on(MediaPlayerEvents.METRIC_ADDED, _onMetricAdded, instance);

        if (performance) {
            performance.addEventListener(
                'resourcetimingbufferfull',
                _onResourceTimingBufferFull,
            );
        }
    }

    function _resetEvents() {
        eventBus.off(MediaPlayerEvents.METRIC_ADDED, _onMetricAdded, instance);

        if (performance) {
            performance.removeEventListener(
                'resourcetimingbufferfull',
                _onResourceTimingBufferFull,
            );
        }
    }

    function _onResourceTimingBufferFull() {
        performance.clearResourceTimings();
    }

    /**
     * Push new values to the throughput model once an HTTP request completed
     * @param {object} e
     * @private
     */
    function _onMetricAdded(e) {
        if (e.metric === MetricsConstants.HTTP_REQUEST && e.value && (e.value.type === HTTPRequest.MPD_TYPE || (e.value.type === HTTPRequest.MEDIA_SEGMENT_TYPE && (e.mediaType === Constants.AUDIO || e.mediaType === Constants.VIDEO)))) {
            throughputModel.addEntry(e.mediaType, e.value);
        }
    }

    /**
     * Get average value
     * @param {string} throughputType
     * @param {string} mediaType
     * @param {string|null} calculationMode
     * @param {number} sampleSize
     * @return {number}
     * @private
     */
    function _getAverage(throughputType, mediaType, calculationMode = null, sampleSize = NaN) {
        let dict = null;
        let ewmaHalfLife = throughputModel.getEwmaHalfLife();
        let halfLife = null;
        let useMin = true;

        if (!calculationMode) {
            calculationMode = settings.get().streaming.abr.throughput.averageCalculationMode;
        }

        switch (throughputType) {

            // Set the parameters for the standard bandwidth calculation based on throughput values for a media type
            case Constants.THROUGHPUT_TYPES.BANDWIDTH:
                dict = calculationMode === Constants.THROUGHPUT_CALCULATION_MODES.EWMA ? throughputModel.getEwmaThroughputDict(mediaType) : throughputModel.getThroughputDict(mediaType);
                halfLife = ewmaHalfLife.bandwidthHalfLife;
                useMin = true;
                sampleSize = !isNaN(sampleSize) ? sampleSize : playbackController.getIsDynamic() ? settings.get().streaming.abr.throughput.sampleSettings.live : settings.get().streaming.abr.throughput.sampleSettings.vod;
                break;

            // Set the parameters for the standard latency calculation based on throughput values for a media type
            case Constants.THROUGHPUT_TYPES.LATENCY:
                dict = calculationMode === Constants.THROUGHPUT_CALCULATION_MODES.EWMA ? throughputModel.getEwmaLatencyDict(mediaType) : throughputModel.getLatencyDict(mediaType);
                halfLife = ewmaHalfLife.latencyHalfLife;
                useMin = false;
                sampleSize = !isNaN(sampleSize) ? sampleSize : settings.get().streaming.abr.throughput.sampleSettings.averageLatencySampleAmount;
                break;

        }

        if (!dict || dict.length === 0) {
            return NaN;
        }

        let adjustedSampleSize;
        switch (calculationMode) {
            case Constants.THROUGHPUT_CALCULATION_MODES.ARITHMETIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getArithmeticMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.BYTE_SIZE_WEIGHTED_ARITHMETIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getByteSizeWeightedArithmeticMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.DATE_WEIGHTED_ARITHMETIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getDateWeightedArithmeticMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.HARMONIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getHarmonicMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.BYTE_SIZE_WEIGHTED_HARMONIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getByteSizeWeightedHarmonicMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.DATE_WEIGHTED_HARMONIC_MEAN:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getDateWeightedHarmonicMean(dict, adjustedSampleSize);
            case Constants.THROUGHPUT_CALCULATION_MODES.EWMA:
                return getEwma(dict, halfLife, useMin);
            case Constants.THROUGHPUT_CALCULATION_MODES.ZLEMA:
                adjustedSampleSize = _getAdjustedSampleSize(dict, sampleSize, throughputType);
                return getZlema(dict, adjustedSampleSize);
        }
    }

    /**
     * @param {array} dict
     * @param {number} sampleSize
     * @param {string} type
     * @return {number}
     * @private
     */
    function _getAdjustedSampleSize(dict, sampleSize, type) {
        if (!dict) {
            sampleSize = 0;
        } else if (sampleSize >= dict.length) {
            sampleSize = dict.length;
        } else if (type === Constants.THROUGHPUT_TYPES.BANDWIDTH && settings.get().streaming.abr.throughput.sampleSettings.enableSampleSizeAdjustment) {
            // if throughput samples vary a lot, average over a wider sample
            for (let i = 1; i < sampleSize; ++i) {
                const ratio = dict[dict.length - i].value / dict[dict.length - i - 1].value;
                if (ratio >= settings.get().streaming.abr.throughput.sampleSettings.increaseScale || ratio <= settings.get().streaming.abr.throughput.sampleSettings.decreaseScale) {
                    sampleSize += 1;
                    if (sampleSize === dict.length) { // cannot increase sampleSize beyond arr.length
                        break;
                    }
                }
            }
        }

        return sampleSize;
    }

    /**
     * Calculate the arithmetic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getArithmeticMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);

        return arr.reduce((total, entry) => {
            return total + entry.value
        }, 0) / arr.length;
    }

    /**
     * Calculates the byte size weighted arithmetic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getByteSizeWeightedArithmeticMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);
        let divideBy = 0;

        return arr.reduce((total, entry) => {
            let weight = Math.sqrt(entry.downloadedBytes);
            divideBy += weight;

            return total + entry.value * weight
        }, 0) / divideBy;
    }

    /**
     * Calculates the time weighted arithmetic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getDateWeightedArithmeticMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);
        let divideBy = 0;

        return arr.reduce((total, entry, index) => {
            let weight = index + 1;
            divideBy += weight;

            return total + entry.value * weight
        }, 0) / divideBy;
    }

    /**
     * Calculate the harmonic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getHarmonicMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);

        const value = arr.reduce((total, entry) => {
            return total + 1 / entry.value
        }, 0);

        return arr.length / value
    }

    /**
     * Calculate the harmonic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getByteSizeWeightedHarmonicMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);
        let dividend = 0;

        const value = arr.reduce((total, entry) => {
            let weight = Math.sqrt(entry.downloadedBytes);
            dividend += weight;

            return total + (1 / entry.value) * weight
        }, 0);

        return dividend / value
    }


    /**
     * Calculates the time weighted harmonic mean of the values provided via the dict
     * @param {array} dict
     * @param {number} sampleSize
     * @return {number|*}
     * @private
     */
    function getDateWeightedHarmonicMean(dict, sampleSize) {
        let arr = dict;

        if (sampleSize === 0 || !arr || arr.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        arr = arr.slice(-sampleSize);
        let dividend = 0;

        const value = arr.reduce((total, entry, index) => {
            let weight = index + 1;
            dividend += weight;

            return total + (1 / entry.value) * weight
        }, 0);

        return dividend / value
    }

    /**
     * Calculated the exponential weighted moving average for the values provided via the dict
     * @param {object} dict
     * @param {object} halfLife
     * @param {boolean} useMin - Whether to apply Math.min of the fastEstimate and the slowEstimate
     * @return {number}
     * @private
     */
    function getEwma(dict, halfLife, useMin = true) {

        if (!dict || dict.totalWeight <= 0) {
            return NaN;
        }

        // to correct for startup, divide by zero factor = 1 - Math.pow(0.5, ewmaObj.totalWeight / halfLife)
        const fastEstimate = dict.fastEstimate / (1 - Math.pow(0.5, dict.totalWeight / halfLife.fast));
        const slowEstimate = dict.slowEstimate / (1 - Math.pow(0.5, dict.totalWeight / halfLife.slow));

        return useMin ? Math.min(fastEstimate, slowEstimate) : Math.max(fastEstimate, slowEstimate);
    }

    /**
     * Calculates the Zero-Lag Exponential Moving Average
     * @param {array} dict
     * @param {number} sampleSize
     * @returns {number}
     */
    function getZlema(dict, sampleSize) {
        if (sampleSize === 0 || !dict || dict.length === 0) {
            return NaN;
        }

        // Extract the last n elements
        let values = dict.slice(-sampleSize).map((entry) => {
            return entry.value;
        })
        let alpha = 2 / (values.length + 1);
        let ema = values[values.length - 1];
        let zlema = values[values.length - 1];

        for (let i = 0; i < values.length; i++) {
            ema = alpha * values[i] + (1 - alpha) * ema;
            zlema = alpha * ema + (1 - alpha) * zlema;
        }

        return zlema;
    }

    /**
     * Returns the average throughput based on the provided calculation mode. The returned value is depicted in kbit/s
     * @param {string} mediaType
     * @param {string | null} calculationMode
     * @param {number | NaN} sampleSize
     * @return {number}
     */
    function getAverageThroughput(mediaType, calculationMode = null, sampleSize = NaN) {
        const value = _getAverage(Constants.THROUGHPUT_TYPES.BANDWIDTH, mediaType, calculationMode, sampleSize);

        return Math.round(value);
    }

    /**
     * Returns the average throughout applying the bandwidth safety factor provided in the settings. The returned value is depicted in kbit/s
     * @param {string} mediaType
     * @param {string | null} calculationMode
     * @param {number | NaN} sampleSize
     * @return {number}
     */
    function getSafeAverageThroughput(mediaType, calculationMode = null, sampleSize = NaN) {
        let average = getAverageThroughput(mediaType, calculationMode, sampleSize);

        if (!isNaN(average)) {
            average *= settings.get().streaming.abr.throughput.bandwidthSafetyFactor;
        }

        return average;
    }

    /**
     * Returns the average latency based on the provided calculation mode
     * @param {string} mediaType
     * @param {string | null} calculationMode
     * @param {number | NaN} sampleSize
     * @return {number}
     */
    function getAverageLatency(mediaType, calculationMode = null, sampleSize = NaN) {
        const value = _getAverage(Constants.THROUGHPUT_TYPES.LATENCY, mediaType, calculationMode, sampleSize);

        return Math.round(value);
    }

    /**
     * Returns the raw throughput measurements without calculating the average
     * @param mediaType
     * @returns {*}
     */
    function getRawThroughputData(mediaType) {
        if (!mediaType) {
            return []
        }
        return throughputModel.getThroughputDict(mediaType);
    }

    function reset() {
        throughputModel.reset();
        _resetEvents();
    }

    const instance = {
        getArithmeticMean,
        getAverageLatency,
        getAverageThroughput,
        getByteSizeWeightedArithmeticMean,
        getByteSizeWeightedHarmonicMean,
        getDateWeightedArithmeticMean,
        getDateWeightedHarmonicMean,
        getEwma,
        getHarmonicMean,
        getRawThroughputData,
        getSafeAverageThroughput,
        getZlema,
        initialize,
        reset,
        setConfig
    };

    return instance;
}

ThroughputController.__dashjs_factory_name = 'ThroughputController';
export default FactoryMaker.getSingletonFactory(ThroughputController);