Add player latency compensator. Closes #837 (#1786)

This commit is contained in:
Gabe Kangas 2022-03-16 18:41:59 -07:00 committed by GitHub
parent 233c2656d8
commit d84da617b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 217 additions and 0 deletions

View file

@ -0,0 +1,215 @@
/*
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.
It will:
- Determine the start (max) and end (min) latency values.
- Keep an eye on download speed and stop compensating if it drops too low.
- Pause the compensation if buffering events occur.
- Completely give up on all compensation if too many buffering events occur.
*/
const BUFFER_LIMIT = 10; // Max number of buffering events before we stop compensating for latency.
const MIN_BUFFER_DURATION = 300; // Min duration a buffer event must last to be counted.
const SPEEDUP_RATE = 1.06; // The playback rate when compensating for latency.
const TIMEOUT_DURATION = 20_000; // The amount of time we stop handling latency after certain events.
const CHECK_TIMER_INTERVAL = 5_000; // How often we check if we should be compensating for latency.
const BUFFERING_AMNESTY_DURATION = 2 * 1000 * 60; // How often until a buffering event expires.
const HIGH_LATENCY_ENABLE_THRESHOLD = 20_000; // ms;
const LOW_LATENCY_DISABLE_THRESHOLD = 4500; // ms;
const REQUIRED_BANDWIDTH_RATIO = 2.0; // The player:bitrate ratio required to enable compensating for latency.
class LatencyCompensator {
constructor(player) {
this.player = player;
this.enabled = true;
this.running = false;
this.inTimeout = false;
this.timeoutTimer = 0;
this.checkTimer = 0;
this.maxLatencyThreshold = 10000;
this.minLatencyThreshold = 8000;
this.bufferingCounter = 0;
this.bufferingTimer = 0;
this.bufferStartedTimestamp = 0;
this.player.on('playing', this.handlePlaying.bind(this));
this.player.on('error', this.handleError.bind(this));
this.player.on('waiting', 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 is run on a timer to check if we should be compensating for latency.
check() {
if (this.inTimeout) {
return;
}
const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
// 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;
if (bandwidthRatio < REQUIRED_BANDWIDTH_RATIO) {
this.timeout();
return;
}
try {
const segment = getCurrentlyPlayingSegment(tech);
if (!segment) {
return;
}
// How far away from live edge do we start the compensator.
this.maxLatencyThreshold = Math.min(
segment.duration * 1000 * 4,
HIGH_LATENCY_ENABLE_THRESHOLD
);
// How far away from live edge do we stop the compensator.
this.minLatencyThreshold = Math.max(
segment.duration * 1000 * 2,
LOW_LATENCY_DISABLE_THRESHOLD
);
const segmentTime = segment.dateTimeObject.getTime();
const now = new Date().getTime();
const latency = now - segmentTime;
if (latency > this.maxLatencyThreshold) {
this.start();
} else if (latency < this.minLatencyThreshold) {
this.stop();
}
} catch (err) {
console.warn(err);
}
}
start() {
if (this.running || this.disabled) {
return;
}
this.running = true;
this.player.playbackRate(SPEEDUP_RATE);
}
stop() {
this.running = false;
this.player.playbackRate(1);
}
// 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() {
this.inTimeout = true;
this.stop();
clearTimeout(this.timeoutTimer);
this.timeoutTimer = setTimeout(() => {
this.endTimeout();
}, TIMEOUT_DURATION);
}
endTimeout() {
clearTimeout(this.timeoutTimer);
this.inTimeout = false;
this.start();
}
handlePlaying() {
if (!this.enabled) {
return;
}
if (this.bufferStartedTimestamp !== 0) {
const bufferingDuration =
new Date().getTime() - this.bufferStartedTimestamp;
this.bufferStartedTimestamp = 0;
// If the buffering event lasted long enough then we will stay in
// a timeout and count it as a real buffering event. Otherwise
// we will ignore it.
if (bufferingDuration > MIN_BUFFER_DURATION) {
this.countBufferingEvent();
} else {
this.endTimeout();
}
}
clearInterval(this.checkTimer);
clearTimeout(this.bufferingTimer);
this.checkTimer = setInterval(() => {
this.check();
}, CHECK_TIMER_INTERVAL);
}
handleEnded() {
this.disable();
}
handleError() {
this.timeout();
}
countBufferingEvent() {
this.bufferingCounter++;
if (this.bufferingCounter > BUFFER_LIMIT) {
this.disable();
return;
}
this.timeout();
// Allow us to forget about old buffering events if enough time goes by.
setTimeout(() => {
if (this.bufferingCounter > 0) {
this.bufferingCounter--;
}
}, BUFFERING_AMNESTY_DURATION);
}
handleBuffering() {
this.bufferStartedTimestamp = new Date().getTime();
this.timeout();
}
}
function getCurrentlyPlayingSegment(tech) {
var target_media = tech.vhs.playlists.media();
var snapshot_time = tech.currentTime();
var segment;
// Itinerate 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;

View file

@ -209,6 +209,8 @@ class OwncastPlayer {
trackElements.addEventListener('cuechange', function (c) {
console.log(c);
});
this.latencyCompensator = new LatencyCompensator(this.vjsPlayer);
});
if (this.appPlayerReadyCallback) {