f6a1b1b638
* chore(deps): update typescript-eslint monorepo to v8 * chore(js): fix linter errors --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Gabe Kangas <gabek@real-ity.com>
490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
/*
|
|
The Owncast Latency Compensator.
|
|
|
|
It will try to slowly adjust the playback rate to enable the player to get
|
|
further into the future, with the goal of being as close to the live edge as
|
|
possible, without causing any buffering events.
|
|
|
|
How does latency occur?
|
|
Two pieces are at play. The first being the server. The larger each segment is
|
|
that is being generated by Owncast, the larger gap you are going to be from
|
|
live when you begin playback.
|
|
|
|
Second is your media player.
|
|
The player tries to play every segment as it comes in.
|
|
However, your computer is not always 100% in playing things in real time, and
|
|
there are natural stutters in playback. So if one frame is delayed in playback
|
|
you may not see it visually, but now you're one frame behind. Eventually this
|
|
can compound and you can be many seconds behind.
|
|
|
|
How to help with this? The Owncast Latency Compensator will:
|
|
- Determine the start (max) and end (min) latency values.
|
|
- Keep an eye on download speed and stop compensating if it drops too low.
|
|
- Limit the playback speedup rate so it doesn't sound weird by jumping speeds.
|
|
- Force a large jump to into the future once compensation begins.
|
|
- Dynamically calculate the speedup rate based on network speed.
|
|
- Pause the compensation if buffering events occur.
|
|
- Completely give up on all compensation if too many buffering events occur.
|
|
*/
|
|
|
|
const REBUFFER_EVENT_LIMIT = 4; // Max number of buffering events before we stop compensating for latency.
|
|
const MIN_BUFFER_DURATION = 200; // Min duration a buffer event must last to be counted.
|
|
const MAX_SPEEDUP_RATE = 1.08; // The playback rate when compensating for latency.
|
|
const MAX_SPEEDUP_RAMP = 0.02; // The max amount we will increase the playback rate at once.
|
|
const TIMEOUT_DURATION = 30 * 1000; // The amount of time we stop handling latency after certain events.
|
|
const CHECK_TIMER_INTERVAL = 3 * 1000; // How often we check if we should be compensating for latency.
|
|
const BUFFERING_AMNESTY_DURATION = 3 * 1000 * 60; // How often until a buffering event expires.
|
|
const REQUIRED_BANDWIDTH_RATIO = 1.8; // The player:bitrate ratio required to enable compensating for latency.
|
|
const HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 2.6; // Segment length * this value is when we start compensating.
|
|
const LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER = 1.8; // Segment length * this value is when we stop compensating.
|
|
const MIN_LATENCY = 4 * 1000; // The absolute lowest we'll continue compensation to be running at.
|
|
const MAX_LATENCY = 15 * 1000; // The absolute highest we'll allow a target latency to be before we start compensating.
|
|
const MAX_JUMP_LATENCY = 5 * 1000; // How much behind the max latency we need to be behind before we allow a jump.
|
|
const MAX_JUMP_FREQUENCY = 20 * 1000; // How often we'll allow a time jump.
|
|
const MAX_ACTIONABLE_LATENCY = 80 * 1000; // If latency is seen to be greater than this then something is wrong.
|
|
const STARTUP_WAIT_TIME = 10 * 1000; // The amount of time after we start up that we'll allow monitoring to occur.
|
|
|
|
function getCurrentlyPlayingSegment(tech) {
|
|
const targetMedia = tech.vhs.playlists.media();
|
|
const snapshotTime = tech.currentTime();
|
|
let segment;
|
|
|
|
// Iterate trough available segments and get first within which snapshot_time is
|
|
// eslint-disable-next-line no-plusplus
|
|
for (let i = 0, l = targetMedia.segments.length; i < l; i++) {
|
|
// Note: segment.end may be undefined or is not properly set
|
|
if (snapshotTime < targetMedia.segments[i].end) {
|
|
segment = targetMedia.segments[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!segment) {
|
|
[segment] = targetMedia.segments;
|
|
}
|
|
|
|
return segment;
|
|
}
|
|
|
|
class LatencyCompensator {
|
|
constructor(player) {
|
|
this.player = player;
|
|
this.playing = false;
|
|
this.enabled = false;
|
|
this.running = false;
|
|
this.inTimeout = false;
|
|
this.jumpingToLiveIgnoreBuffer = false;
|
|
this.timeoutTimer = 0;
|
|
this.checkTimer = 0;
|
|
this.bufferingCounter = 0;
|
|
this.bufferingTimer = 0;
|
|
this.playbackRate = 1.0;
|
|
this.lastJumpOccurred = null;
|
|
this.startupTime = new Date();
|
|
this.clockSkewMs = 0;
|
|
this.currentLatency = null;
|
|
|
|
// Keep track of all the latencies we encountered buffering events
|
|
// in order to determine a new minimum latency.
|
|
this.bufferedAtLatency = [];
|
|
|
|
this.player.on('playing', this.handlePlaying.bind(this));
|
|
this.player.on('pause', this.handlePause.bind(this));
|
|
this.player.on('error', this.handleError.bind(this));
|
|
this.player.on('waiting', this.handleBuffering.bind(this));
|
|
this.player.on('stalled', this.handleBuffering.bind(this));
|
|
this.player.on('ended', this.handleEnded.bind(this));
|
|
this.player.on('canplaythrough', this.handlePlaying.bind(this));
|
|
this.player.on('canplay', this.handlePlaying.bind(this));
|
|
|
|
this.check = this.check.bind(this);
|
|
this.start = this.start.bind(this);
|
|
this.enable = this.enable.bind(this);
|
|
this.countBufferingEvent = this.countBufferingEvent.bind(this);
|
|
}
|
|
|
|
// To keep our client clock in sync with the server clock to determine
|
|
// accurate latency the clock skew should be set here to be used in
|
|
// the calculation. Otherwise if somebody's client clock is significantly
|
|
// off it will have a very incorrect latency determination and make bad
|
|
// decisions.
|
|
setClockSkew(skewMs) {
|
|
this.clockSkewMs = skewMs;
|
|
}
|
|
|
|
// This is run on a timer to check if we should be compensating for latency.
|
|
check() {
|
|
// We have an arbitrary delay at startup to allow the player to run
|
|
// normally and hopefully get a bit of a buffer of segments before we
|
|
// start messing with it.
|
|
if (new Date().getTime() - this.startupTime.getTime() < STARTUP_WAIT_TIME) {
|
|
return;
|
|
}
|
|
|
|
// If we're paused then do nothing.
|
|
if (this.player.paused()) {
|
|
return;
|
|
}
|
|
|
|
if (this.player.seeking()) {
|
|
return;
|
|
}
|
|
|
|
if (this.inTimeout) {
|
|
return;
|
|
}
|
|
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
|
|
|
|
// We need access to the internal tech of VHS to move forward.
|
|
// If running under an Apple browser that uses CoreMedia (Safari)
|
|
// we do not have access to this as the tech is internal to the OS.
|
|
if (!tech || !tech.vhs) {
|
|
return;
|
|
}
|
|
|
|
// Network state 2 means we're actively using the network.
|
|
// We only want to attempt latency compensation if we're continuing to
|
|
// download new segments.
|
|
const networkState = this.player.networkState();
|
|
if (networkState !== 2) {
|
|
return;
|
|
}
|
|
|
|
let totalBuffered = 0;
|
|
|
|
try {
|
|
// Check the player buffers to make sure there's enough playable content
|
|
// that we can safely play.
|
|
if (tech.vhs.stats.buffered.length === 0) {
|
|
this.timeout();
|
|
return;
|
|
}
|
|
|
|
tech.vhs.stats.buffered.forEach(buffer => {
|
|
totalBuffered += buffer.end - buffer.start;
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
// Determine how much of the current playlist's bandwidth requirements
|
|
// we're utilizing. If it's too high then we can't afford to push
|
|
// further into the future because we're downloading too slowly.
|
|
const currentPlaylist = tech.vhs.playlists.media();
|
|
const currentPlaylistBandwidth = currentPlaylist.attributes.BANDWIDTH;
|
|
const playerBandwidth = tech.vhs.systemBandwidth;
|
|
const bandwidthRatio = playerBandwidth / currentPlaylistBandwidth;
|
|
|
|
try {
|
|
const segment = getCurrentlyPlayingSegment(tech);
|
|
if (!segment) {
|
|
return;
|
|
}
|
|
|
|
// If we're downloading media fast enough or we feel like we have a large
|
|
// enough buffer then continue. Otherwise timeout for a bit.
|
|
if (bandwidthRatio < REQUIRED_BANDWIDTH_RATIO && totalBuffered < segment.duration * 6) {
|
|
this.timeout();
|
|
return;
|
|
}
|
|
|
|
// How far away from live edge do we stop the compensator.
|
|
const computedMinLatencyThreshold = Math.max(
|
|
MIN_LATENCY,
|
|
segment.duration * 1000 * LOWEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER,
|
|
);
|
|
|
|
// Create an array of all the buffering events in the past along with
|
|
// the computed min latency above.
|
|
const targetLatencies = this.bufferedAtLatency.concat([computedMinLatencyThreshold]);
|
|
|
|
// Determine if we need to reduce the minimum latency we computed
|
|
// above based on buffering events that have taken place in the past by
|
|
// creating an array of all the buffering events and the above computed
|
|
// minimum latency target and averaging all those values.
|
|
const minLatencyThreshold =
|
|
targetLatencies.reduce((sum, current) => sum + current, 0) / targetLatencies.length;
|
|
|
|
// How far away from live edge do we start the compensator.
|
|
let maxLatencyThreshold = Math.max(
|
|
minLatencyThreshold * 1.4,
|
|
Math.min(segment.duration * 1000 * HIGHEST_LATENCY_SEGMENT_LENGTH_MULTIPLIER, MAX_LATENCY),
|
|
);
|
|
|
|
// If this newly adjusted minimum latency ends up being greater than
|
|
// the previously computed maximum latency then reset the maximum
|
|
// value using the minimum + an offset.
|
|
if (minLatencyThreshold >= maxLatencyThreshold) {
|
|
maxLatencyThreshold = minLatencyThreshold + 3000;
|
|
}
|
|
|
|
const segmentTime = segment.dateTimeObject.getTime();
|
|
const now = new Date().getTime() + this.clockSkewMs;
|
|
const latency = now - segmentTime;
|
|
this.currentLatency = latency;
|
|
|
|
// Since the calculation of latency is based on clock times, it's possible
|
|
// things can be reported incorrectly. So we use a sanity check here to
|
|
// simply bail if the latency is reported to so high we think the whole
|
|
// thing is wrong. We can't make decisions based on bad data, so give up.
|
|
// This can also occur if somebody pauses for a long time and hits play
|
|
// again but it's not really possible to know the difference between
|
|
// the two scenarios.
|
|
if (Math.abs(latency) > MAX_ACTIONABLE_LATENCY) {
|
|
this.timeout();
|
|
return;
|
|
}
|
|
|
|
if (latency > maxLatencyThreshold) {
|
|
// If the current latency exceeds the max jump amount then
|
|
// force jump into the future, skipping all the video in between.
|
|
if (this.shouldJumpToLive() && latency > maxLatencyThreshold + MAX_JUMP_LATENCY) {
|
|
const jumpAmount = latency / 1000 - segment.duration * 3;
|
|
const seekPosition = this.player.currentTime() + jumpAmount;
|
|
console.info(
|
|
'latency',
|
|
latency / 1000,
|
|
'jumping',
|
|
jumpAmount,
|
|
'to live from ',
|
|
this.player.currentTime(),
|
|
' to ',
|
|
seekPosition,
|
|
);
|
|
|
|
// Verify we have the seek position buffered before jumping.
|
|
const availableBufferedTimeEnd = tech.vhs.stats.buffered[0].end;
|
|
const availableBufferedTimeStart = tech.vhs.stats.buffered[0].start;
|
|
if (seekPosition > availableBufferedTimeStart < availableBufferedTimeEnd) {
|
|
this.jump(seekPosition);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Using our bandwidth ratio determine a wide guess at how fast we can play.
|
|
let proposedPlaybackRate = bandwidthRatio * 0.33;
|
|
|
|
// But limit the playback rate to a max value.
|
|
proposedPlaybackRate = Math.max(Math.min(proposedPlaybackRate, MAX_SPEEDUP_RATE), 1.0);
|
|
|
|
if (proposedPlaybackRate > this.playbackRate + MAX_SPEEDUP_RAMP) {
|
|
// If this proposed speed is substantially faster than the current rate,
|
|
// then allow us to ramp up by using a slower value for now.
|
|
proposedPlaybackRate = this.playbackRate + MAX_SPEEDUP_RAMP;
|
|
}
|
|
|
|
// Limit to 3 decimal places of precision.
|
|
proposedPlaybackRate = Math.round(proposedPlaybackRate * 10 ** 3) / 10 ** 3;
|
|
|
|
// Otherwise start the playback rate adjustment.
|
|
this.start(proposedPlaybackRate);
|
|
} else if (latency <= minLatencyThreshold) {
|
|
this.stop();
|
|
}
|
|
|
|
console.info(
|
|
'latency',
|
|
latency / 1000,
|
|
'min',
|
|
minLatencyThreshold / 1000,
|
|
'max',
|
|
maxLatencyThreshold / 1000,
|
|
'playback rate',
|
|
this.playbackRate,
|
|
'enabled:',
|
|
this.enabled,
|
|
'running: ',
|
|
this.running,
|
|
'skew: ',
|
|
this.clockSkewMs,
|
|
'rebuffer events: ',
|
|
this.bufferingCounter,
|
|
);
|
|
} catch {
|
|
// console.error(err);
|
|
}
|
|
}
|
|
|
|
shouldJumpToLive() {
|
|
// If we've been rebuffering some recently then don't make it worse by
|
|
// jumping more into the future.
|
|
if (this.bufferingCounter > 1) {
|
|
return false;
|
|
}
|
|
|
|
const now = new Date().getTime();
|
|
const delta = now - this.lastJumpOccurred;
|
|
return delta > MAX_JUMP_FREQUENCY;
|
|
}
|
|
|
|
jump(seekPosition) {
|
|
this.jumpingToLiveIgnoreBuffer = true;
|
|
this.performedInitialLiveJump = true;
|
|
|
|
this.lastJumpOccurred = new Date();
|
|
|
|
console.info('current time', this.player.currentTime(), 'seeking to', seekPosition);
|
|
this.player.currentTime(seekPosition);
|
|
|
|
setTimeout(() => {
|
|
this.jumpingToLiveIgnoreBuffer = false;
|
|
}, 5000);
|
|
}
|
|
|
|
setPlaybackRate(rate) {
|
|
this.playbackRate = rate;
|
|
this.player.playbackRate(rate);
|
|
}
|
|
|
|
start(rate = 1.0) {
|
|
if (this.inTimeout || !this.enabled || rate === this.playbackRate) {
|
|
return;
|
|
}
|
|
|
|
this.running = true;
|
|
this.setPlaybackRate(rate);
|
|
}
|
|
|
|
stop() {
|
|
if (this.running) {
|
|
console.log('stopping latency compensator...');
|
|
}
|
|
this.running = false;
|
|
this.setPlaybackRate(1.0);
|
|
}
|
|
|
|
enable() {
|
|
this.enabled = true;
|
|
clearInterval(this.checkTimer);
|
|
clearTimeout(this.bufferingTimer);
|
|
|
|
this.checkTimer = setInterval(() => {
|
|
this.check();
|
|
}, CHECK_TIMER_INTERVAL);
|
|
}
|
|
|
|
// Disable means we're done for good and should no longer compensate for latency.
|
|
disable() {
|
|
clearInterval(this.checkTimer);
|
|
clearTimeout(this.timeoutTimer);
|
|
this.stop();
|
|
this.enabled = false;
|
|
}
|
|
|
|
timeout() {
|
|
if (this.jumpingToLiveIgnoreBuffer) {
|
|
return;
|
|
}
|
|
|
|
this.inTimeout = true;
|
|
this.stop();
|
|
|
|
clearTimeout(this.timeoutTimer);
|
|
this.timeoutTimer = setTimeout(() => {
|
|
this.endTimeout();
|
|
}, TIMEOUT_DURATION);
|
|
}
|
|
|
|
endTimeout() {
|
|
clearTimeout(this.timeoutTimer);
|
|
this.inTimeout = false;
|
|
}
|
|
|
|
handlePlaying() {
|
|
const wasPreviouslyPlaying = this.playing;
|
|
this.playing = true;
|
|
|
|
clearTimeout(this.bufferingTimer);
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
if (!this.shouldJumpToLive()) {
|
|
return;
|
|
}
|
|
|
|
// If we were not previously playing (was paused, or this is a cold start)
|
|
// seek to live immediately on starting playback to handle any long-pause
|
|
// scenarios or somebody starting far back from the live edge.
|
|
// If we were playing previously then that means we're probably coming back
|
|
// from a rebuffering event, meaning we should not be adding more seeking
|
|
// to the mix, just let it play.
|
|
if (!wasPreviouslyPlaying) {
|
|
this.jumpingToLiveIgnoreBuffer = true;
|
|
this.player.liveTracker.seekToLiveEdge();
|
|
this.lastJumpOccurred = new Date();
|
|
}
|
|
}
|
|
|
|
handlePause() {
|
|
this.playing = false;
|
|
}
|
|
|
|
handleEnded() {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
this.disable();
|
|
}
|
|
|
|
handleError() {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
|
|
this.timeout();
|
|
}
|
|
|
|
countBufferingEvent() {
|
|
this.bufferingCounter += 1;
|
|
|
|
if (this.bufferingCounter > REBUFFER_EVENT_LIMIT) {
|
|
this.disable();
|
|
return;
|
|
}
|
|
|
|
this.bufferedAtLatency.push(this.currentLatency);
|
|
|
|
console.log(
|
|
'latency compensation timeout due to buffering:',
|
|
this.bufferingCounter,
|
|
'buffering events of',
|
|
REBUFFER_EVENT_LIMIT,
|
|
);
|
|
|
|
// Allow us to forget about old buffering events if enough time goes by.
|
|
setTimeout(() => {
|
|
if (this.bufferingCounter > 0) {
|
|
this.bufferingCounter -= 1;
|
|
}
|
|
}, BUFFERING_AMNESTY_DURATION);
|
|
}
|
|
|
|
handleBuffering() {
|
|
if (!this.enabled || this.inTimeout) {
|
|
return;
|
|
}
|
|
|
|
if (this.jumpingToLiveIgnoreBuffer) {
|
|
this.jumpingToLiveIgnoreBuffer = false;
|
|
return;
|
|
}
|
|
|
|
this.timeout();
|
|
|
|
clearTimeout(this.bufferingTimer);
|
|
this.bufferingTimer = setTimeout(() => {
|
|
this.countBufferingEvent();
|
|
}, MIN_BUFFER_DURATION);
|
|
}
|
|
}
|
|
|
|
export default LatencyCompensator;
|