Add latency compensator to player. Closes #1931
This commit is contained in:
@@ -1,512 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
|
||||
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) {}
|
||||
|
||||
// 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.
|
||||
var 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 * Math.pow(10, 3)) / Math.pow(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 (err) {
|
||||
// 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(e) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeout();
|
||||
}
|
||||
|
||||
countBufferingEvent() {
|
||||
this.bufferingCounter++;
|
||||
|
||||
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--;
|
||||
}
|
||||
}, 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);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentlyPlayingSegment(tech) {
|
||||
var target_media = tech.vhs.playlists.media();
|
||||
var snapshot_time = tech.currentTime();
|
||||
|
||||
var segment;
|
||||
|
||||
// Iterate trough available segments and get first within which snapshot_time is
|
||||
for (var i = 0, l = target_media.segments.length; i < l; i++) {
|
||||
// Note: segment.end may be undefined or is not properly set
|
||||
if (snapshot_time < target_media.segments[i].end) {
|
||||
segment = target_media.segments[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!segment) {
|
||||
segment = target_media.segments[0];
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
export default LatencyCompensator;
|
||||
Reference in New Issue
Block a user