utilities/timingUtility.js

'use strict';

const _isNil = require('lodash/isNil');
const debug = require('debug')('k4-base-bridge:timingUtility');
/**
 * @class
 * @description A general-purpose set of utility and helper functions
 * for timer-based operations.
 */
class TimingUtility {
	/**
	 * @property {Object} constants
	 * @static
	 */
	static get constants() {
		return {
			ALLOWED_PERCENT_TOLERANCE: 20,
			MIN_ACCEPTABLE_FRAMES_PER_INTERVAL: 20,
			MAX_ACCEPTABLE_FRAMES_PER_INTERVAL: 50
		};
	}

	/**
	 * @description Wraps Node.js setTimeout() in a thenable. It has some
	 * additional computation to guarantee that at least the specified
	 * delay duration has elapsed.
	 * @param {Number} delay The minimum wait duration (msec)
	 * @returns {Promise}
	 * @static
	 */
	static setTimeoutPromise(delay) {
		return new Promise((resolve, reject) => {
			const specifiedDelayNanos = delay * 1e6;
			const startTime = process.hrtime();
			const startTimeNanos = this.convertHrTimeToNanos(startTime);

			setTimeout(() => {
				const currentTime = process.hrtime();
				const currentTimeNanos = this.convertHrTimeToNanos(currentTime);
				const actualDelayNanos = currentTimeNanos - startTimeNanos;
				if (actualDelayNanos >= specifiedDelayNanos) {
					resolve();
				} else {
					const remainderDelayMsec = Math.ceil((specifiedDelayNanos - actualDelayNanos) / 1e6);
					setTimeout(() => {
						resolve();
					}, remainderDelayMsec);
				}
			}, delay);
		});
	}

	/**
	 * @description Wraps Node.js setImmediate() in a thenable/Promise-like
	 * @returns {Promise}
	 * @static
	 */
	static setImmediatePromise() {
		return new Promise((resolve, reject) => {
			setImmediate(resolve);
		});
	}

	/**
	 * @description Runs a recurring interval timer that checks for its deadline
	 * at multiple smaller frames which are smaller than the
	 * specified interval. Depends on the base method:
	 * {@link TimingUtility#selfCorrectingInterval}
	 * @param callback
	 * @param {Number} intervalDuration Must be at least 100 ms
	 * @param {Number} [numFramesPerInterval] The number of frames to divide the
	 * interval into. Default is 20 frames per interval. The acceptable divisions
	 * are currently between 20 and 40 frames per interval.
	 * Currently, only the 20 frames per interval option is supported with a
	 * known tolerance of +/- 20%.
	 * @returns {Object} The frameIntervalTimer field yields a: reference to an
	 * object whose 'timer' field refers to a NodeJS.Timer
	 * @static
	 */
	static setIntervalByFrames(callback, intervalDuration, numFramesPerInterval) {
		const numFrames = !_isNil(numFramesPerInterval) ? numFramesPerInterval : 20;
		const frameDuration = intervalDuration / numFrames;
		let startingTime = Date.now();

		if (numFrames > TimingUtility.constants.MAX_ACCEPTABLE_FRAMES_PER_INTERVAL || numFrames < TimingUtility.constants.MIN_ACCEPTABLE_FRAMES_PER_INTERVAL) {
			throw new Error(`The specified number of frames ${numFrames}, is beyond the acceptable range of [${TimingUtility.constants.MIN_ACCEPTABLE_FRAMES_PER_INTERVAL}, ${TimingUtility.constants.MAX_ACCEPTABLE_FRAMES_PER_INTERVAL}] (inclusive)`);
		}

		const timerObj = TimingUtility.selfCorrectingInterval(() => {
			const currentTime = Date.now();
			const timeElapsed = currentTime - startingTime;

			if ((currentTime - (timeElapsed - intervalDuration)) <= 0) {
				debug(`[setIntervalByFrames] currentTime, timeElapsed, intervalDuration, nextStartingTime: `, currentTime, timeElapsed, intervalDuration, Date.now() - (timeElapsed - intervalDuration));
			}

			if (timeElapsed >= intervalDuration) {
				startingTime = Date.now() - (timeElapsed - intervalDuration);
				callback();
			}
		}, frameDuration);

		timerObj.toleranceMsec = ((TimingUtility.constants.ALLOWED_PERCENT_TOLERANCE) / 100) * intervalDuration;
		return timerObj;
	}

	/**
	 * @description A self-correcting recurring interval timer. Intended maximum
	 * correction resolution is the interval duration up to +/- the allowed
	 * percent tolerance by TimingUtility (currently 20%).
	 * @param {Function} callback Function to call once each interval
	 * @param {Number} intervalDuration Delay duration (msec)
	 * @returns {Object} Reference to an object whose 'timer' field refers to
	 * a NodeJS.Timer
	 * @static
	 */
	static selfCorrectingInterval(callback, intervalDuration) {
		let previousTime = Date.now();
		let desiredDuration = intervalDuration;
		const retObj = {};

		function tick() {
			const now = Date.now();
			const actualDuration = now - previousTime;
			previousTime = now;
			desiredDuration = intervalDuration - (actualDuration - desiredDuration);
			callback();

			retObj.timer = setTimeout(tick, desiredDuration);
		}

		retObj.timer = setTimeout(tick, intervalDuration);
		retObj.delayInterval = intervalDuration;
		retObj.toleranceMsec = ((TimingUtility.constants.ALLOWED_PERCENT_TOLERANCE) / 100) * intervalDuration;
		return retObj;
	}

	/**
	 * @description Converts the process.hrtime() tuple to a single numerical
	 * value.
	 * @param {Number[]} hrTimeTuple The original result of process.hrtime()
	 * @returns {Number} The time value in nanoseconds
	 * @static
	 */
	static convertHrTimeToNanos(hrTimeTuple) {
		return (hrTimeTuple[0] * 1e9) + hrTimeTuple[1];
	}
};

module.exports = TimingUtility;